All posts

Building the agency platform we couldn't buy - one workspace for the team, a dashboard per client

An internal Laravel platform that gives our team time tracking, task management, and a client switcher in one workspace - while each client gets a branded login with their own reporting, SEO tools, paid ads visibility, and CMS integrations. The case for building the thing instead of buying twelve things.

Most agencies I've worked with run on the same stack: Asana or ClickUp for tasks, Toggl for time, Slack for chat, Looker Studio for client reporting, Ahrefs or SEMrush for SEO data, a Google Drive folder per client, a shared spreadsheet of passwords nobody touches, and a half-dozen platform UIs the team logs into directly. It works. It also bleeds eight hours a week per person to context switching and "where did we put that thing."

Six months ago I started building the platform we couldn't buy: one Laravel application that holds the team's workflow on one side and a per-client SaaS portal on the other. Internal staff sign in and see a workspace - tasks, time tracking, task assignment, every client one click away through a switcher in the header. Clients sign in to the same domain with their own credentials and see only their own brand, their own reporting, their own tools. Same database, different lenses.

This is the post about the design decisions behind it.

Why not buy?

The honest answer is that the math stops working. Every SaaS tool in the agency stack charges per seat. The good ones charge $30 to $80 per seat per month. With ten people on the team and twelve tools, you're spending five figures a year before anyone has done any work. And you still have the integration problem - none of those tools know about each other.

The deeper answer is that the integrations between tools are exactly where the leverage lives. When a tool knows that a Google Ads spend anomaly is happening for the same client whose monthly task is "audit ad spend," it can put those two things next to each other on the team member's home screen. None of the off-the-shelf tools can do this because none of them know everything about that client.

The build started as "let me try replacing Asana for one client" and within two months had grown teeth: time tracking, task management, client switching, AI-assisted task generation, reporting, SEO tools, content production, paid ads dashboards. Not because we set out to build a giant thing, but because each next feature was three days of work on top of infrastructure we already had.

The two-sided architecture

The thing that makes this not just another agency CRM is the dual-mode design. Same Laravel app, two completely different experiences depending on who's logged in.

The team side: when an internal user logs in, the header shows a client switcher. Currently working on Client A, click the dropdown, switch to Client B. The entire app re-scopes - dashboards, reports, tasks, content production, every URL is now showing Client B's data. The team member sees their full nav (Operations, Internal Clients, Team Management, Alice Prompts for super admins). All the platform tools are visible.

The client side: when a client user logs in to the same URL, they see only their own slice. No switcher. No internal nav sections. The header brand colors are their brand colors, the avatar is their avatar, the dashboard is their dashboard. As far as they can tell, this is a SaaS tool built for them.

The middleware that makes this work is two lines. Every internal-only route is gated by EnsureAlgoistTeam which checks is_internal = true on the user model. Every client-scoped query reads from a per-request $activeCompanyId that's set when an internal user picks from the switcher or inherently set to the client user's own company. The database schema has a company_id column on every domain table and Laravel's query scopes enforce it.

A 403 if you somehow visit a URL outside your scope. A redirect to the client's own dashboard if you try to navigate to an internal-only route. No tenant data leakage by design.

Time tracking, but for pay periods

The team time tracking is one of the most-used features in the workspace, and the most-overlooked decision was the choice of period boundary.

Most time tracking tools default to weekly periods. Some let you customize. Almost none ship with the boundary that actually matters for a small agency: the pay period.

We pay on the 8th and the 22nd of each month - two pay periods, each roughly two weeks. The time tracking page calculates the current pay period boundary based on today's date, computes "hours logged so far in this period," shows pace vs target with a color-coded progress bar, and lists every session in the period with a one-click "Allocate to client" button.

The pay period boundaries matter because reconciliation against payroll matters. A weekly report that ends on Sunday is useless if you pay on the 8th - somebody has to manually slice the week. By making the period boundary match the pay schedule, the report and the paycheck align without anyone doing math.

Below that: a Pay Period History accordion showing the last eight complete periods. Click any period to expand its session table. The history is read-only, audit-grade, immutable. Nobody can retroactively edit a closed period - that's a deliberate constraint to keep the data trustworthy across audits.

The client switcher is the magic trick

If you take nothing else away from this post, take this: the single feature that turns this from "another internal tool" into "the platform" is the client switcher.

Every meaningful page is scoped to a company_id. The switcher in the header changes the active company in session and reloads the current view in the new scope. So a team member doing a CTR audit for Client A can click the switcher, switch to Client B, and see Client B's CTR audit on the same screen layout without having to navigate back to a dashboard and find the right page.

The cumulative effect over a week is something like an hour reclaimed per team member. Multiply by ten people. Multiply by 50 weeks. Half a person's worth of productivity recovered from context-switching alone.

The hard part wasn't building the switcher. It was making sure every controller, every Blade view, every scheduled job, every SQL query was scoped properly. Six months in, we've had exactly zero incidents of one client's data appearing in another client's view. That's not luck. That's the result of every domain table having a company_id foreign key and every Eloquent query having a global scope that enforces it.

What the team sees, what the client sees

Both sides share the same database and most of the same UI shell - but the feature surface is dramatically different.

Team-side features:

  • Dashboard with task feed, time tracking summary, RSS feeds from Google Search Central and other vendor blogs, recently-worked items
  • Task management with internal-only focus types (Algo Internal, Algo Onboarding) that never appear to clients
  • Time tracking with pay periods, session allocation, history
  • Client list with VCI status (the internal account-tier system), search, filters, last-pull timestamps
  • An internal Alice Prompts editor where the team can edit the AI system prompts used across the platform without a code deploy
  • Server details dashboard (which physical servers are running which client applications, payment terms, plan tiers)
  • Team management with role assignment (Algo Ops Expert, Content Manager, Campaign Manager, Spec)

Client-side features:

  • Their own branded dashboard with stat tiles, mini editorial calendar, task progress
  • Reporting (GSC, GA4, ad platforms - whatever they're connected to)
  • SEO tools: cannibalization detection, keyword intelligence, CTR performance tracking, on-page optimization, structured data, technical optimization (Core Web Vitals)
  • Competitor gap analysis with up to 5 competitors, opportunity classification across nine types (Missing, Quick Win, High-Value Gap, etc.)
  • Content marketing: planning, keyword research, content generation (AI-assisted), editorial calendar, repository
  • A right-click context menu in the editor for "Simplify, Expand, Punchier, Formal, Conversational, Add Keyword" rewrites
  • CMS integrations: WordPress push pipeline, featured image generation via DALL-E, image upload to a private S3 bucket served via CloudFront with Origin Access Control

The client never sees the team-side. The team can see both - their own workspace, and any client's portal via the switcher.

The "never expose vendor names" rule

One business-critical rule that shaped a lot of the UI copy: third-party vendor names are never visible to clients.

The platform pulls data from DataForSEO, OpenAI, Anthropic, and a handful of other vendors. Internally, we say "DataForSEO" in code and in admin pages. But on every client-facing surface - tooltips, badges, error messages, footers, empty states - those vendor names get replaced with generic language.

"Search API" instead of "DataForSEO." "Live data" instead of "DataForSEO pull." "Via Google Trends" for trend data (because Google Trends is the underlying source the client recognizes) rather than the data vendor in between.

This matters because clients aren't paying us so we can resell vendor APIs to them. They're paying us for our judgment about which data matters and how to present it. The moment "DataForSEO" appears in a client tooltip, the client wonders why they're not just buying DataForSEO directly. The vendor name is a leak of agency margin.

This rule is enforced architecturally - the gate is route prefix. Anything under /internal/ is allowed to show vendor names; nothing else is. A static check in the CI pipeline could verify this, and adding that check is on the wishlist.

On AI in the platform

The platform has AI in a lot of places. Task action generation, learning guide creation, content rewriting, schema assessment, technical SEO assessment, full-report generation across organic / technical / content / backlink / priority-actions sections.

The same rule from earlier posts applies here: AI writes the commentary, not the numbers. Every number that gets shown to a user is pulled from a database before the AI prompt is constructed and inserted verbatim. The AI is explicitly told to never invent numbers. When it fails (and it sometimes does - LLMs are pleasers), the schema validation catches it and the user gets an error instead of a hallucinated metric.

The other rule that's been important: AI prompts live in a database table, not in code. There's an internal Alice Prompts editor at /internal/alice-prompts where a super admin can edit any of the fifteen prompts used across the platform and have the change take effect on the next AI call without a deploy. This sounds boring but it matters - prompt engineering is iterative, and the iterations don't deserve a release cycle each.

What I would change

A few months in, a few things would be different on a second build:

Multi-tenant from day one, not retrofitted. I started this as a single-tenant tool and added the company switcher and tenant scoping later. It worked but every query had to be hunted for tenant-correctness. Starting with row-level security in the database (Postgres has it built in; MySQL via discipline) would have saved a lot of manual auditing.

A managed cloud database instead of self-hosted MySQL. Same story as the ad-spend dashboard post. Managed cloud Postgres or BigQuery would simplify backups, point-in-time recovery, and replication. The cost difference at agency-data volumes is negligible compared to the time saved on the deployment story.

Separate the front-end build into its own deploy artifact. Right now Vite-compiled assets are built on the server after each git pull. This works but means the deploy is "git pull → npm build → artisan optimize:clear" which is three steps where it could be one. Building the assets in CI and shipping a pre-built tarball would tighten this.

But the core promise - that a ten-person agency can run on one Laravel application that holds both the team's workflow and every client's portal - has held up. We're paying for AWS, DataForSEO, OpenAI, GitHub, and a managed host. That's it. The compounding effect of "everything is one query away" against "everything is one tab away" is the kind of leverage you don't feel until you've worked both ways.

If you're an agency thinking about the build vs buy decision, the question isn't whether the tools you'd buy are good. They are. The question is whether the integrations between those tools are where your real productivity lives. For us, they were. So we built the thirteenth tool that talks to all twelve.