Local Subdomain Development

For local development you don't need to edit /etc/hosts, set up dnsmasq, or run a DNS server. Every modern browser resolves *.localhost to 127.0.0.1 automatically — this is specified by RFC 6761. The docker compose dev stack uses this.

What works out of the box

With bin/dev-setup.sh running:

URL What happens
http://localhost:3000 Native Next.js dev server — no tenant, hits landlord/platform UI
http://demo.localhost:3000 Same Next.js server — window.location.hostname is demo.localhost → frontend extracts demo as the tenant → sends X-Tenant: demo on API calls
http://acme.localhost:3000 Same, but for tenant acme
http://localhost:8000/api/v1/... Direct backend API — expects X-Tenant header or hits central routes
http://demo.localhost:8000/api/v1/... Backend sees Host: demo.localhost → Stancl's InitializeTenancyByDomain resolves demo

No configuration, no DNS edits. All major browsers (Chrome, Firefox, Safari, Edge) resolve *.localhost to 127.0.0.1 or ::1 without asking the network.

What does NOT work locally

Non-browser tools sometimes don't resolve *.localhost. The RFC says they should, but:

  • curl works ✓
  • httpie works ✓
  • Native macOS ping may skip DNS and go straight to .local mDNS — use curl instead if you need to hit a subdomain from the terminal
  • wget works ✓
  • Postman / Insomnia work ✓

Testing the subdomain flow end-to-end

  1. Bring up docker dev + native frontend:

    bin/dev-setup.sh
    cd frontend && bun run dev
    
  2. Hit the tenant subdomain:

    curl -sS -X POST http://demo.localhost:8000/api/v1/auth/login \
      -H "Content-Type: application/json" \
      -H "X-Tenant: demo" \
      -d '{"email":"owner@demo.com","password":"password"}'
    
  3. In the browser: open http://demo.localhost:3000/login — the frontend auto-extracts demo from window.location.hostname and includes the X-Tenant header on every API request. You don't need to set anything.

Adding a new local tenant

Create a tenant directly in the central DB:

docker compose -p autocom-dev exec app php artisan tinker --execute='
use App\Models\Tenant;
use App\Models\User;
use App\Models\TenantUser;
use Illuminate\Support\Facades\Hash;

$t = Tenant::create(["id"=>"localshop", "name"=>"Local Shop", "plan"=>"free"]);
$t->domains()->create(["domain" => "localshop"]);

$u = User::create([
  "name" => "Local Admin",
  "email" => "admin@localshop.com",
  "password" => Hash::make("password"),
  "email_verified_at" => now(),
]);

TenantUser::create([
  "tenant_id" => "localshop",
  "user_id" => $u->id,
  "role_id" => \Spatie\Permission\Models\Role::where("name","owner")->first()->id,
  "status" => "active",
]);

echo "Ready → http://localshop.localhost:3000" . PHP_EOL;
'

Browse to http://localshop.localhost:3000 → log in → you're in the new tenant's context.

Why your prod setup will "just work"

The browser's *.localhost shortcut lets you exercise the exact same subdomain-based tenant resolution that runs in production. The Laravel middleware, Stancl bootstrappers, and frontend subdomain extraction all run unchanged. When you move to prod, the only piece that changes is where DNS resolves — see K8s Wildcard Setup for the single wildcard A record that replaces *.localhost.

Put differently: if a tenant flow works with demo.localhost:3000, it works with demo.autocom.app without code changes.

Debugging tenant resolution

If a subdomain isn't resolving to the right tenant, walk the chain in reverse:

# 1. Is the Host header reaching the backend?
curl -sS -v http://demo.localhost:8000/api/v1/install/check 2>&1 | grep -i "host:"
# Expect: Host: demo.localhost:8000

# 2. Does the `domains` table have a row for `demo`?
docker compose -p autocom-dev exec pgsql \
  psql -U autocom -d autocom -c "SELECT domain, tenant_id FROM domains WHERE domain = 'demo';"

# 3. Does the tenant exist?
docker compose -p autocom-dev exec pgsql \
  psql -U autocom -d autocom -c "SELECT id, name, status FROM tenants WHERE id = 'demo';"

# 4. Does Laravel see it?
docker compose -p autocom-dev exec app php artisan tinker --execute='
  echo \App\Models\Tenant::find("demo")?->name ?? "not found";
'

If all four succeed and the frontend still can't log in, check the browser's network tab for the outgoing X-Tenant header.