Skip to main content
| 6 min read | By Eyal Gantz, Laravel developer & fractional CTO, NYC

How I Run 20+ Production Websites on One Laravel Codebase

Laravel Architecture Multi-Domain

I run more than twenty production websites out of a single Laravel 12 codebase: one repository, one deploy pipeline, one set of shared infrastructure. Each site is a bounded context under app/Domain/, with its own routes file, its own Blade views, and its own tests. In production the router matches requests with Route::domain(), so teamwil.co and eyalgantz.com resolve to different route trees inside the same application. Locally that same loop switches to Route::prefix(), so I browse every site under one Valet host with no local TLS certificates to manage. Static content lives in Sushi models instead of database tables, which means most of these sites carry no schema at all. Contact forms, security headers, spam filtering, and rate limiting are written once and inherited everywhere. The tradeoff is real: one bad shared middleware change breaks twenty sites at once. In exchange, a new site costs a folder, a routes file, and a deploy.

What does "one codebase, many domains" actually mean?

The deployed artifact is identical for every site. One composer.json, one build, one queue, one Nova admin, one deploy target. What varies per site is a folder of PHP, a routes file, and a folder of views.

The router does the separating. bootstrap/app.php reads every file in routes/domain/, and the filename is the hostname:

routes/domain/teamwil.co.php
routes/domain/eyalgantz.com.php
routes/domain/ron-gabay.co.il.php

Today that directory holds 38 route files across 23 bounded contexts. The count is higher than the site count because some properties carry a second hostname: a .madebywil.co preview subdomain used before a client points DNS, or a www. variant that needs its own redirect rules.

How does the routing switch between production and development?

One line, decided at boot:

$glue = app()->environment('production') ? 'domain' : 'prefix';

$domains->each(
    fn ($domain) => Route::$glue($domain)
        ->middleware('web')
        ->group(base_path("routes/domain/$domain.php"))
);

In production that resolves to Route::domain('teamwil.co'), which is strict host matching: a request for the wrong host never reaches that route tree. Locally it resolves to Route::prefix('teamwil.co'), so I open http://wilco-junction.test/teamwil.co/ and get the same routes without editing /etc/hosts or provisioning 38 local certificates.

Two consequences are worth knowing before you copy this pattern.

First, route names are global. Two sites cannot both register a route named contact.store. I namespace every name by domain (eyalgantz.com.about, team-wilco.index), which is also what makes route() calls readable across contexts.

Second, API routes get registered once, globally, outside the domain loop. An earlier version of this file registered them inside the loop, which duplicated every named API route once per domain. That works fine until php artisan route:cache runs, at which point serialization fails on the duplicate names.

Why is each site a bounded context instead of a folder of controllers?

Because the alternative rots fast. With 23 sites in one app, a flat app/Http/Controllers directory becomes a place where nobody can tell which class belongs to which client, and shared helpers quietly grow client-specific branches.

Instead every site owns a namespace:

app/Domain/EyalGantz/
    Models/
    Services/
    Livewire/
    Http/Controllers/
    Enums/

Anything genuinely shared moves to app/Domain/Common on purpose, not by accident. Security lives in app/Domain/Security. Tests mirror the same shape under tests/Feature/Domain/{Name}/, so I can run one site's suite with a filter and know I have covered it.

The practical benefit is deletion. When a project ends, I delete one folder, one routes file, one views folder, and one test folder. Nothing else in the app notices.

Where does the content live when there is no database?

In Sushi models. Sushi lets an Eloquent model define its rows in PHP and get a queryable in-memory SQLite table at runtime. About two dozen models in this app use it: portfolio projects, team members, music tracks, blocked IP ranges.

This post is served that way. BlogPost scans markdown files in resources/content/blog/, parses the YAML frontmatter, renders the body, and returns rows:

class BlogPost extends Model
{
    use Sushi;

    public function getRows(): array
    {
        // parse resources/content/blog/*.md into rows
    }
}

From there it behaves like any other model: BlogPost::published()->latest()->paginate(10). No migration, no seeder, no admin UI to maintain, and the content is version-controlled and reviewable in a pull request.

Sites that need real persistence still get real tables: leads, orders, security events, and anything a client edits through an admin UI. The rule I follow is that admin-editable tables are owned by the admin, so no migration or seeder ever writes rows into them.

What does every new domain inherit for free?

This is the part that makes the architecture pay for itself. A new site starts with:

  • A contact form chain: validation, Cloudflare Turnstile, spam middleware, SES delivery, and a rate limiter capped at 2 submissions per minute and 10 per hour
  • Security headers and a CSP with per-request nonces, applied globally
  • IP blocking, access-denied logging, and security event tracking
  • Beacon analytics with its own limiter (60 per minute, 1000 per hour)
  • Laravel Nova for admin, plus Telescope, Nightwatch, and Flare for monitoring
  • One asset pipeline, one queue, one deploy to Laravel Cloud

Building any single one of those for a one-off marketing site is hard to justify. Building them once for 23 sites is easy.

What actually breaks with this setup?

Four things, honestly:

Blast radius. A regression in ConsolidatedSecurityMiddleware is a regression on every domain. Shared middleware gets tests before it gets deployed.

Health checks. Laravel's framework health route at /up returns 404 here, because strict Route::domain() matching means an unmatched host has no route to hit. The canonical probe is a global /health route in routes/web.php that pings the database and cache and refuses to be cached.

Shared cache and config. Cache keys need a domain component or two sites will read each other's data. This bites once, then you namespace everything.

Suite growth. The test suite covers 23 sites, so running everything is slow. I run scoped tests by domain during development and let CI carry the full run.

When is this the wrong architecture?

When one site's traffic or release cadence dominates the others. Shared deploys mean a hotfix for one client ships everyone else's pending changes at the same time, so if a property needs its own release train, it needs its own repository.

It is also wrong when the sites are genuinely one product with tenants. That is multi-tenancy, and it wants tenant-scoped queries and a package built for it, not a route file per hostname.

For a portfolio of independent small properties that share a builder, this shape is the cheapest one I have found. New idea, new folder, live URL the same day.

Share this article

Stay in the loop

Get notified when I publish new articles about building products, AI, and lessons from the trenches.

Get in Touch