Skip to content
Mickey Liechtenstein
All cases

Custom client portal and back office

Mickeyr.dk Client Portal

A custom Laravel platform for SEO, tasks, documents, and finances — one simple view for the client, and a far more detailed back office behind it.

A client portal quickly becomes worthless if it is just one more place to log in to find the same information that already lives in emails, spreadsheets, and various third-party systems. So the goal of the portal at portal.mickeyr.dk has never been to build a dashboard for the dashboard's sake. It should gather what is actually relevant in the work with my clients and make it easier to understand and act on.

An SEO client should be able to follow progress without drowning in raw data from different tools. A client with a development task should be able to see what is being worked on, what is waiting on them, and what the next step is. Reports should be findable again, and invoices should line up with the finances I work in myself. At the same time, the client should never see internal notes, time tracking, technical checks, or billing status — that belongs solely in my own back office.

It may sound like one portal. Technically and functionally, it is deliberately built as two.

portal.mickeyr.dk
The client overview: finances, SEO development, and the biggest movements — gathered in one place.
The client overview: finances, SEO development, and the biggest movements — gathered in one place.

The screenshots use fictional example data.

Two users, two systems

One of the first decisions in the project was that the client portal and my internal administration system should not be the same interface with different menu items. They are separated all the way through: the client logs into the client portal, and I work in a separate back office. The two sides can happily share the same underlying clients, tasks, documents, and invoices, but they have fundamentally different purposes. The client needs an overview. The back office needs to handle the details.

It means, for example, that a client can see that a task is waiting for material, while internally I have the time spent, technical notes, billing status, and next internal action sitting on that same task. It is the same data — but it is not the same need. The principle runs through the whole solution: the client's view should answer the questions the client actually has, while the internal view is allowed to be far more detailed.

In plain terms

Picture a workshop. The customer needs to know what will be done, when the car is ready, and what it will cost. The mechanic, meanwhile, needs fault codes, part numbers, time tracking, and internal notes. You could show it all on one screen and just hide some fields from the customer — but I chose instead to build two different work surfaces around the same data. It gives a cleaner experience for the client and a far clearer security boundary for me.

Technical detail

The solution is built in Laravel 13 with Filament v5 and MySQL, where the client portal and the back office are two separate panels. The back office deliberately has no login page of its own — a visitor without access is sent back to the client login. That is not a security mechanism in itself; the real access control sits behind it. But there is also no reason to advertise unnecessarily where the administration area lives.

No one can just sign themselves up

There is no public “Create account” function — neither for the client portal nor the administration. When a client needs access, the user is created administratively and then receives an email where they choose their own password. That gives me control over who is linked to which company, while I never need to know or choose the client's password.

That matters especially in a B2B portal, where a user is not just “a user”. The user represents a specific client and should therefore only be able to see information belonging to exactly that client.

Security lives in the data layer — not in the interface

One of the most important architectural decisions concerns something the client will hopefully never notice: data isolation. If two companies use the same portal, company A must of course never be able to see company B's information — not even if someone tries to change an ID in a URL, tamper with a form, or call a function in a way the normal interface never would.

So the client relationship is not checked only when data is about to be shown. It is already part of how data is fetched. In practice, the system does not find a task and then ask “is the client allowed to see it?”. It looks for the task among this particular client's tasks. If it isn't there, it doesn't exist for the client.

It is also why an attempt to reach a foreign resource returns “not found” rather than “access denied”. A message about missing access indirectly reveals that the resource exists. A 404 does not. The client should not be able to use the system's responses to map out data they don't have access to.

Access control does not stop at login either. If a client or user is deactivated mid-session, access should not carry on for the rest of the day just because a login succeeded earlier — status is checked continuously, and access stops at the next request. It is the kind of security that rarely makes for a nice screenshot in a case study, but that decides whether a portal merely looks professional or also behaves that way.

And because it is easy to write “remember to filter by client” and just as easy to forget it once, the isolation between clients is covered by automated tests. A new feature must not be able to open a path to foreign data just because a query was written wrong. Important guarantees should as far as possible be enforced by the system — not depend on me remembering them the next time I change something. That principle shows up again in billing, deployment, and document sharing.

Modules rather than one big portal

Not all clients have the same needs. A hosting client may mainly need invoices and documents, an SEO client needs to follow rankings and backlinks, and a client with ongoing development work needs to follow tasks, estimates, and deliverables. So the portal is built modularly, with features that can be switched on and off per client. The solution can be extended with new areas without making the portal more complex for everyone else — a client shouldn't have six empty menu items just because another client uses six modules.

SEO without sending the client off to an SEO tool

The SEO module is a good example of why I wanted my own portal. There are already plenty of excellent SEO platforms, but I don't want my clients to have to learn a particular third-party system, understand a vendor's metrics, or think about where the numbers come from. They buy an SEO service from me, so the experience should be mine too.

The SEO module, white-labelled: the client sees their own keywords, rankings, and trend — not the name of the data provider behind it.
The SEO module, white-labelled: the client sees their own keywords, rankings, and trend — not the name of the data provider behind it.

The screenshots use fictional example data.

The module can draw on different data sources depending on the job, but the client experience is 100% white-label. The client doesn't see the name of the data provider behind it — the client sees their own keywords, rankings, trend, competitors, and links. What matters is the result, not which API delivered the number.

SEO data costs money to fetch, and different types of data change at different speeds. A keyword ranking can be worth checking several times a day, a backlink analysis doesn't need re-buying every six hours, and competitor data typically changes even more slowly. So the system works with different freshness windows — keywords update more often than links, and links more often than competitors. The interesting part is that the scheduler doesn't necessarily fetch data. It first asks whether the data is old enough for it to make sense. If it's still fresh, nothing happens. That keeps the API calls down and makes the costs more predictable.

Technical detail

Each data type has its own freshness logic. A scheduled command can be run as a dry-run, so I can see exactly what the system would fetch and why, without spending money on data. The balance with the data providers is monitored automatically, and I'm notified before a low balance has a chance to turn into missing updates. Not spectacular — but that kind of operational visibility is the difference between an integration that works during a demo and one you can rely on month after month.

I choose the keywords worth monitoring, and the client can then follow them in the portal. If a keyword sits outside the top 100, it doesn't disappear from the report — it stays on the list with a clear note that it is not currently ranking in the top 100. That is deliberate. A report doesn't become more valuable because the bad results vanish. If an important keyword moves from position 76 to 34, that's interesting. If it moves from 11 to 4, that's interesting. And if it drops from 8 to 29, that's at least as interesting. So the portal tracks movement both ways — it isn't built to show only green arrows.

There are plenty of impressive-looking SEO numbers: traffic value, authority, spam score, and a whole range of other composite metrics. Some can be useful in the right context. But if I don't think a number can be explained properly and used for something concrete, it shouldn't be in the portal just to make the dashboard look advanced. I'd rather show fewer numbers that can be defended than more that create an illusion of precision.

External APIs fail, get slow, and occasionally return strange responses. If the portal read straight from the data provider, that would quickly surface as errors, empty charts, or slow pages. It doesn't. Data is fetched in the background and stored locally, and the portal always shows the latest good measurements. If a new lookup fails, the client still has yesterday's numbers while the system retries in the background. In other words, a brief failure at a provider doesn't have to become the client's problem. Data is also bought and processed in small units, so a single keyword that fails doesn't stop the rest. Three failures in a row on the same element trigger a message to me — but the system doesn't spam the same alert, and when a later update succeeds, the error history is reset. The client doesn't need to know any of it. That's mine to handle.

Tasks are more than “open” and “closed”

The task module started with a simple question: what does the client actually need to see to understand where a task stands? It's tempting to settle for one status — new, in progress, waiting, done — but reality is more nuanced. A task can be in progress and at the same time waiting for material from the client. It can be built but not yet deployed. Delivered but not yet invoiced. Estimated but not yet accepted. Squeeze all of that into one field and the status quickly becomes meaningless.

Tasks as the client sees them: where it stands, what is waiting on them, and what happens next.
Tasks as the client sees them: where it stands, what is waiting on them, and what happens next.

The screenshots use fictional example data.

So a task works with several independent dimensions: where it is in the process, who currently holds it, whether something is being awaited and from whom, what has been estimated, and how it should be invoiced. The client's view gathers that into something easy to understand. The back office is allowed to keep the complexity.

An estimate is part of the agreement. If a task is first estimated at 8 hours and later changed to 14, it shouldn't look as if it always said 14. So estimates are not overwritten — a change creates a new version, so both the original assessment and the later changes can be documented. When an estimate is sent to the client, the relevant terms are locked, so texts and assumptions can't afterwards be changed into something other than what the client actually received. That makes for a fairer process on both sides.

A task's history is built as a log: new events are added, old ones aren't edited away, and there is no ordinary delete path. That means a course of events can always be followed afterwards — who did what, when the status changed, when the estimate was sent, when the task was deployed. It's useful on big projects, but perhaps even more so on the small tasks that would otherwise end up scattered across 37 emails over six months.

Once a task is deployed, it doesn't need to stay active forever just because no one got around to clicking “close”. So the system can close it itself after three days — but only if the client hasn't commented in the meantime. If the client replies “it doesn't quite work on mobile”, an automated job obviously shouldn't come along afterwards and declare the matter closed. Automation only adds value once it also understands when not to automate.

Finances without magic numbers

The finance module is built on the same philosophy. There have to be clear rules for when something is a draft, when it is a real invoice, and what may be changed afterwards. So an invoice number is only assigned when the invoice is actually issued. A draft can be created, changed, and deleted without using a number in the sequence, but once the invoice is issued, it is treated as the document it is. It isn't simply edited afterwards — if something needs correcting, that happens through a credit note.

portal.mickeyr.dk/fakturaer
An invoice is treated as the document it is — not a field that can quietly be edited afterwards.
An invoice is treated as the document it is — not a field that can quietly be edited afterwards.

The screenshots use fictional example data.

There are also deliberate rules about rounding. VAT is calculated on the summed basis, and amounts are rounded once, in a controlled way, rather than being rounded in several places along the way. It isn't a feature any client gets excited about — but it's exactly the kind of thing a finance system should be boringly correct about.

Recurring agreements can automatically create invoice drafts overnight, but the system doesn't send bills off on its own. A human still has to issue them, and that's deliberate. There's plenty I'm happy to automate — but sending binding financial documents with no chance of a last human glance is not one of them. The same goes for sending: issuing an invoice and sending it are two different actions, so issuing it can't accidentally end up sending an email as a side effect. A task can, incidentally, form the basis of an invoice draft and even suggest billing based on the tracked time — but the key word is suggest. It's a tool to save manual work, not a decision machine.

Documents can be shared without making storage public

Another module handles documents and files — reports, documentation, or other material to be delivered to a client. The client can get access two ways: through their normal client portal, or through a special share link that doesn't require a login. The latter is handy when a document needs to go to someone who doesn't have a user in the portal. The two access paths are deliberately built independently of each other.

A share link opens a document overview — not just the first file — so files can be swapped without sending a new link.
A share link opens a document overview — not just the first file — so files can be swapped without sending a new link.

The screenshots use fictional example data.

Uploading is not the same as publishing. If I upload four files and notice that one is wrong, the client shouldn't necessarily have had a chance to see it. So upload and publish are two different actions: files can be prepared, checked, and swapped out, and only then is the material made available. A small difference in the interface — a big difference in the workflow.

A document can consist of several files, so the share link points to the document's overview rather than directly at the first file. That way I can add or replace files later without sending the client a whole new link, and the client gets a clear overview of the shared material. Even though a document can be opened through a public link, the files don't sit in a public folder. The public link grants access through the application, not directly to storage, and when a file is to be shown or downloaded, the system first checks that it actually belongs to the document that the share token in question grants access to. So you can't take the link to one file, change the file's ID, and start pulling other clients' documents.

Technical detail

A share token belongs to exactly one document, and the files are found through the document relation — not the other way around. It's the same design principle as the rest of the portal: access should be restricted where the data is fetched, not just in the link or button the user sees. The same document can, incidentally, have several share links, each with its own expiry, its own history, and its own revocation — so one link can be closed without touching the others.

The sharing feature records when a link was opened, when the overview was shown, and when a file was opened or downloaded. Here a classic real-world detail turned up: many mail systems and security products scan links automatically before the mail reaches the recipient. If such a visit counted as a real open, some documents would look as if they'd been opened almost the moment the mail was sent. So automatic scanner signals are stored, but don't count as a genuine human open. That makes for less impressive statistics — but truer statistics.

Security is also what happens outside Laravel

The application is only one layer. On the server the portal runs isolated with its own system user, its own PHP process pool, and its own database, and file access is restricted. The public share pages have a strict Content Security Policy, and the whole portal host is disallowed in robots.txt, because a client portal has no business in Google's index. Share tokens are also redacted out of the mail logs, so a sensitive link doesn't end up as plain text in a log file.

Uploaded files are handled defensively too. The system doesn't blindly trust the filename or MIME type the client sends along — the file type is judged from the file's actual content. Formats like SVG and HTML are never shown inline directly from the document area, so a file that looks innocent on upload can't later be used as active content in the browser.

The best testing tool turned out to be production

There are automated tests. Plenty of them — 289 at the time of writing. A large share concern access control, client separation, and the rules that must not be broken as the system is developed further. But the project has also confirmed something else: a green test suite doesn't mean the work is done. Three of the most instructive bugs weren't found by reading the code, but by checking the actually deployed solution.

A release was missing part of the mail design. A rule in the process that built the release package unintentionally excluded the mail theme. The code looked correct, and the tests could be green — but the files simply didn't make it onto the server. The fix wasn't “remember to check for it next time”, but an automatic check that fails the deployment if the required files are missing.

The right code was in the wrong archive. Another bug was down to an old release archive: the correct code existed, but it wasn't the code being deployed. Here too I could have settled for a note about remembering to check the date — instead the process was changed so the system detects the discrepancy itself.

The invoice worked — the email didn't. An invoice could be created correctly, but a configuration difference on the server meant the invoice email could fail when processed through the queue. It wasn't enough to check that the invoice existed; the whole flow had to be tested end to end — from action to queue to email to the actual result.

Technical detail

None of the three bugs were closed with “now I know”. They were closed with checks. That's perhaps the most important technical principle in the whole project: if something is important enough that it must never happen again, the solution should as far as possible detect it itself. Humans are good at solving new problems. Computers are good at remembering the old ones.

In plain terms: what did I actually build?

Strip Laravel, middleware, queues, tokens, and API calls out of the story and it's quite simple: I've built one shared place for my clients' work with me. There they can follow SEO results, tasks, documents, and finances, and they only see what is relevant and understandable to them. Behind it I have a separate workspace for all the detail it takes to deliver the service. When external services fail, the system tries to handle it without making it the client's problem. When something financial is to be sent, it still takes a human choice. When a document is shared, the file is still protected. And when I spot an operational bug, I don't just fix it — I try to build a check that catches it next time.

Built for the next module — not just the current one

The portal isn't meant to be a finished product with a fixed list of features. It's the foundation for the way I want to work with clients going forward, and new modules can be added without every existing client automatically getting more complexity. It could be new forms of reporting, approval flows, integrations, hosting status, automated analyses, or service agreements — or something I haven't needed yet. What matters is that the foundation already understands clients, users, access, modules, history, and the boundaries between internal and external information. That makes the next feature easier to build correctly.

The result

The client portal has gone from the idea of “a place where the client can see their things” to a single platform for a steadily growing part of the work between me and my clients. Today the solution includes, among other things:

  • A client portal and a separate internal back office
  • Module-based access per client
  • Strict isolation between clients' data — covered by tests
  • SEO reporting with white-label data
  • Automatic, cost-aware updating of SEO data
  • Fault-tolerant handling of external data sources
  • Task management with versioned estimates and a full history
  • Invoices, credit notes, and automatic drafts with human approval
  • Private document handling with share links, expiry, and revocation
  • Detailed event tracking and operational monitoring
  • Server-level isolation and 289 automated tests

But the number of features isn't the most important result. The most important thing is that the complexity sits in the right place. The client doesn't need to understand my data provider, the billing statuses behind the scenes, or how a document file is authorised. The client should be able to get answers to: How is it going? What's being worked on? Is there anything I need to do? What have we agreed? Where do I find the material? Those are the questions the portal has to answer. The rest is my work.

Custom development makes sense when the workflow matters more than the standard system

I could have gathered a handful of existing SaaS tools and given clients access to each of them — one for SEO, one for tasks, one for document sharing, one for invoices, and a fifth for reporting. It would have been faster to begin with, but it wouldn't have created the client experience or the internal coherence I want. Instead the solution is built around the workflow itself. It's also the kind of custom development I find most interesting: not software for software's sake, but systems that remove unnecessary intermediaries, gather the information, and make a concrete process easier to work in.

Should I build something similar for your business?

Many businesses end up with a mix of spreadsheets, emails, Dropbox folders, CRM systems, project tools, and manual routines that each work fine on their own — but don't work particularly well together. That's often where a custom portal or an internal system starts to make sense. I help clarify the workflow first and the technology afterwards: if an existing standard system solves the job best, I'll say so. If the need instead sits between the systems, I can build exactly the part that's missing.

Have you got a workflow that has outgrown the standard tools?

I build client portals, internal systems, and integrations around the way a business actually works — with a focus on ownership, security, and room to build on.

Shall we have a chat?

Tell me briefly about your task — I will get back to you with an honest take on what makes sense.

Write to me

Describe your task in a few lines and I will get back to you — usually the same day.

Your details are used only to reply — no newsletter.