The Complete Guide to Laravel 13 Multi-Tenancy with Teams

Build production-ready team-based multi-tenancy on Laravel 13's first-party starter kits. URL routing, scoped queries, invitations, tenant-aware jobs, and Pest tests.

Steven Richardson
Steven Richardson
· 18 min read

Multi-tenancy in Laravel used to be the part of the project nobody wanted to own. You'd start with Jetstream teams, hit the "two browser tabs share the same active team" bug within a week, then either rip it out or layer on stancl/tenancy. Laravel 13 finally settles this with URL-based teams baked into the new starter kits — /teams/{team:slug}/projects is now the canonical pattern, the active team comes from the route, and every Eloquent query you scope to it stays correct across tabs, async jobs, and broadcast channels. This guide is the production playbook: scaffolding, scoping, invitations, jobs, broadcasting, and testing — all on Laravel 13.

What you'll learn#

  • Why Laravel 13 brought first-party teams back, and how URL-based context fixes the session-based bugs
  • How to scaffold a team-aware app from the new starter kit and customise the team model
  • How to bind the current team in middleware and scope every Eloquent query with a global scope plus a Tenant trait
  • How invitations, role enforcement, and team-aware policies work end-to-end
  • When to choose shared-database tenancy vs database-per-tenant — with the migration patterns for both
  • How to keep queued jobs, notifications, and broadcast channels tenant-aware
  • How to test multi-tenant features properly with Pest (and why your assertions should expect 404, not 403)

Why teams came back to first-party scaffolding#

For five years, Laravel's official answer to "how do I build a SaaS with teams?" was either Jetstream or a third-party package. Jetstream worked, but it stored the active team in users.current_team_id — a session-bound integer that broke the moment a customer opened two tabs. Switch tenants in tab A, then click a link in tab B, and you'd silently mutate or read the wrong team's data. The community workarounds — URL parameters, custom middleware, signed routes — all converged on the same fix: put the team in the URL, and stop relying on session state to remember which tenant the request belongs to.

Laravel 13 codifies that pattern into the starter kits. Every team-scoped route lives under /teams/{team:slug}/..., the team parameter resolves through implicit model binding, and the Auth::user()->teams relationship plus a slim CurrentTeam resolver replaces current_team_id. The result is a tenancy story that looks a lot like Stripe Connect or Linear: the URL is the tenant context, not session state, and a copy-pasted link from a colleague always lands you in the right team.

If you're upgrading an older app, the Laravel 12 to 13 upgrade guide covers the breaking changes around session shape and the team migration helpers. The teams story specifically lives in the starter-kit scaffolding — your existing app keeps working with whatever you had before, but the new patterns assume URL-bound teams from day one.

The same shift makes the rest of the framework cleaner. Tenant-aware notifications no longer have to grep current_team_id from a serialised user. Queued jobs carry the team explicitly. Broadcast channels are namespaced by team slug. PHP 8 attributes — covered in the Laravel 13 PHP attributes deep dive — are used internally to mark middleware as TenantAware, which we'll use later to keep workers honest.

A quick scope note: this guide focuses on the application side of tenancy — scoping data, isolating jobs, securing routes. It's not a packaging guide for stancl/tenancy. If you need full tenant-database isolation with multi-tenant queues and per-tenant cache prefixes, that package is still excellent and Laravel 13's URL-team primitives compose with it. We'll touch on database-per-tenant briefly in a later section.

Setting up the Laravel 13 starter kit with teams#

Start from a fresh starter kit. The teams feature is opt-in via a flag during installation:

laravel new acme --starter-kit=breeze --teams
cd acme
php artisan migrate --no-interaction

The flag publishes three things you don't get on a vanilla install: a teams table, a team_user pivot with a role column, and a CurrentTeam resolver registered as a singleton in the container. The starter kit also rewrites the dashboard route group to mount under /teams/{team:slug}/..., with team implicit-bound to a Team model and gated through a MembersOnly middleware.

The migrations look like this. They're worth reading because the column shape drives every later decision:

// database/migrations/xxxx_create_teams_table.php
Schema::create('teams', function (Blueprint $table) {
    $table->id();
    $table->foreignId('owner_id')->constrained('users')->cascadeOnDelete();
    $table->string('name');
    $table->string('slug')->unique();
    $table->boolean('personal_team')->default(false);
    $table->timestamps();
});

Schema::create('team_user', function (Blueprint $table) {
    $table->id();
    $table->foreignId('team_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('role')->default('member');
    $table->timestamps();
    $table->unique(['team_id', 'user_id']);
});

The Team model ships with two relationships: members() (belongs-to-many through team_user) and owner() (belongs-to users). The User model gets a reciprocal teams() relationship plus a personalTeam() accessor. The slug column is the URL-binding key, generated from the team name on creation and editable via the team settings page. Slugs must be globally unique — not per-owner — because they appear in the URL.

Roles are stored as strings in the pivot. Use a backed enum to keep them type-safe — see the PHP enums and strategy pattern article for the full pattern. The minimum:

namespace App\Enums;

enum TeamRole: string
{
    case Owner = 'owner';
    case Admin = 'admin';
    case Member = 'member';
    case Viewer = 'viewer';

    public function canManageMembers(): bool
    {
        return in_array($this, [self::Owner, self::Admin], strict: true);
    }
}

Cast the pivot's role column to the enum and you'll never compare raw strings again.

Bind the current team in middleware and the container#

Implicit model binding gets the Team model into your controller, but the rest of the application — service classes, jobs, notifications — needs the same context without threading it through every method signature. The starter kit registers a CurrentTeam singleton; bind it in a middleware that runs after the team has been resolved:

namespace App\Http\Middleware;

use App\Models\Team;
use App\Support\CurrentTeam;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class BindCurrentTeam
{
    public function __construct(private CurrentTeam $current) {}

    public function handle(Request $request, Closure $next): Response
    {
        $team = $request->route('team');

        if (! $team instanceof Team) {
            abort(404);
        }

        if (! $request->user()?->belongsToTeam($team)) {
            abort(404);
        }

        $this->current->set($team);

        return $next($request);
    }
}

Two things to notice. First, abort(404) is intentional for the not-a-member case — never 403. Returning 403 leaks the existence of the team to anyone who can guess a slug. Pretend the team doesn't exist for non-members and search engines won't index private team pages. Second, CurrentTeam is a stateful singleton — perfectly safe inside a request lifecycle but explicitly reset on queue worker boot (more on that in the jobs section).

CurrentTeam itself is a thin wrapper:

namespace App\Support;

use App\Models\Team;
use RuntimeException;

class CurrentTeam
{
    private ?Team $team = null;

    public function set(Team $team): void
    {
        $this->team = $team;
    }

    public function get(): Team
    {
        return $this->team
            ?? throw new RuntimeException('No team bound on this request.');
    }

    public function id(): int
    {
        return $this->get()->id;
    }

    public function forget(): void
    {
        $this->team = null;
    }
}

Register the middleware on the team route group in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'team.bind' => \App\Http\Middleware\BindCurrentTeam::class,
    ]);
})

Then the route group:

Route::middleware(['auth', 'team.bind'])
    ->prefix('teams/{team:slug}')
    ->group(function () {
        Route::get('/', [DashboardController::class, 'show'])->name('team.dashboard');
        Route::resource('projects', ProjectController::class);
    });

Implicit model binding handles the lookup; the middleware enforces membership and binds the singleton. Every controller, service, job, and Blade view inside this group can now resolve CurrentTeam from the container without ever asking the URL again.

Scope every Eloquent query with a Tenant trait#

This is where most multi-tenant projects accidentally leak. You scope queries in the obvious controllers, then somebody writes Project::find($id) in an artisan command, a job, or a deeply nested service — and a member of Team A reads Team B's data. The fix is to make tenant scoping the default behaviour of every tenant-owned model, with an explicit opt-out for the cases that genuinely need it.

A Tenant trait does both:

namespace App\Models\Concerns;

use App\Models\Team;
use App\Support\CurrentTeam;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Scope;

trait Tenant
{
    public static function bootTenant(): void
    {
        static::addGlobalScope(new TenantScope());

        static::creating(function (Model $model) {
            if (! $model->team_id && app(CurrentTeam::class)->get()) {
                $model->team_id = app(CurrentTeam::class)->id();
            }
        });
    }

    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }
}

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if ($team = optional(app(CurrentTeam::class))->get()) {
            $builder->where($model->qualifyColumn('team_id'), $team->id);
        }
    }
}

Every tenant-owned model uses the trait and adds a team_id foreign key:

class Project extends Model
{
    use Tenant;

    protected $fillable = ['name', 'description'];
}

Project::all() now returns only the current team's projects. Project::create(['name' => 'Launch']) automatically stamps the team_id. To bypass — say, in a global admin panel — call Project::withoutGlobalScope(TenantScope::class). Make that an explicit, code-reviewable line, not the default.

A few production hardenings worth stating up front:

  • Add a database-level (team_id, slug) unique constraint on anything tenants can name. Application scoping isn't a substitute for foreign-key integrity.
  • Index team_id on every tenant table. Always. Without it, the global scope turns every list query into a full table scan as the data grows.
  • Combine the Tenant trait with readonly value objects for DTOs that cross the tenant boundary — it forces you to pass identifiers explicitly when you genuinely need to.

Invitations, roles, and team-aware policies#

Invitations are the part that drifts furthest from the docs in real apps, because every product invents its own flow. Stick to one of two shapes: signed-link invitations (no account required, click-through to sign-up) or account-required invitations (existing users only, accept inside the dashboard). The starter kit ships both behind a feature flag in config/teams.php.

The TeamInvitation model:

class TeamInvitation extends Model
{
    use Tenant;

    protected $fillable = ['email', 'role', 'token', 'expires_at'];

    protected function casts(): array
    {
        return [
            'role' => TeamRole::class,
            'expires_at' => 'datetime',
        ];
    }

    public function isExpired(): bool
    {
        return $this->expires_at?->isPast() ?? false;
    }
}

The signed-link flow uses URL::temporarySignedRoute with the team's slug and a one-time token:

use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;

class InviteTeamMember
{
    public function __invoke(Team $team, string $email, TeamRole $role): TeamInvitation
    {
        $invitation = $team->invitations()->create([
            'email' => $email,
            'role' => $role,
            'token' => Str::random(48),
            'expires_at' => now()->addDays(7),
        ]);

        $url = URL::temporarySignedRoute(
            'team.invitations.accept',
            $invitation->expires_at,
            ['team' => $team->slug, 'token' => $invitation->token]
        );

        Mail::to($email)->queue(new TeamInvitationMail($team, $url));

        return $invitation;
    }
}

The acceptance controller validates the signature, attaches the user to the team with the stored role, and deletes the invitation atomically:

class AcceptInvitationController
{
    public function __invoke(Request $request, Team $team, string $token)
    {
        abort_unless($request->hasValidSignature(), 403);

        $invitation = $team->invitations()
            ->where('token', $token)
            ->whereNull('accepted_at')
            ->firstOrFail();

        if ($invitation->isExpired()) {
            return redirect()->route('login')->withErrors([
                'invitation' => 'This invitation has expired.',
            ]);
        }

        DB::transaction(function () use ($team, $invitation, $request) {
            $team->members()->syncWithoutDetaching([
                $request->user()->id => ['role' => $invitation->role->value],
            ]);
            $invitation->update(['accepted_at' => now()]);
        });

        return redirect()->route('team.dashboard', $team);
    }
}

For authorisation, prefer a single TeamPolicy plus per-resource policies that delegate role checks to the pivot row. The role enum's canManageMembers() method becomes the policy method body — and now the policy reads like the product spec:

class TeamPolicy
{
    public function manageMembers(User $user, Team $team): bool
    {
        return $user->roleOn($team)?->canManageMembers() ?? false;
    }
}

The roleOn(Team) method looks up the pivot and casts to the enum. That's it.

Tenant-aware queued jobs and notifications#

Queued jobs are the silent killer of multi-tenancy. A job dispatched inside a request runs later, on a worker process that has no current team. If you read CurrentTeam::get() inside handle(), you'll either get null or — worse — leak a team from a different request that happened to run on the same worker. Both are bugs. The fix is TenantAware middleware: capture the team at dispatch time, re-bind it before handle() runs, and forget it afterwards.

namespace App\Jobs\Middleware;

use App\Models\Team;
use App\Support\CurrentTeam;
use Closure;

class TenantAware
{
    public function __construct(public int $teamId) {}

    public function handle(object $job, Closure $next): void
    {
        $current = app(CurrentTeam::class);
        $current->set(Team::withoutGlobalScope(TenantScope::class)->findOrFail($this->teamId));

        try {
            $next($job);
        } finally {
            $current->forget();
        }
    }
}

Then on the job:

class ProcessProjectExport implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $projectId) {}

    public function middleware(): array
    {
        return [new TenantAware(app(CurrentTeam::class)->id())];
    }

    public function handle(): void
    {
        $project = Project::findOrFail($this->projectId);
        // CurrentTeam is bound — global scope applies, $project belongs to the right tenant.
    }
}

A few patterns to keep this honest:

  • Pass IDs, not models. Models in queue payloads serialise too much state and bypass the tenant scope when they hydrate. IDs force a fresh query that the global scope catches.
  • Pair this with the Laravel queues production guide for failure handling — failed_jobs gets a team_id column so you can show tenants their own retries.
  • Notifications follow the same pattern. The starter kit's broadcast channel naming is teams.{team:id}.{user:id} — see the Laravel Reverb real-time notifications guide for the WebSocket side.

For per-tenant rate limits, use the keyed limiter — RateLimiter::for('exports', fn ($r) => Limit::perMinute(10)->by($r->user()->teamRoute())) — combined with the patterns in Laravel rate limiting for API routes. One tenant hammering the queue should never starve another's workers.

Shared database vs database-per-tenant#

The default for any new SaaS should be shared database with a team_id foreign key on every tenant-owned table. It's cheaper, simpler to operate, easier to back up, and lets you ship cross-tenant analytics without exporting data warehouses. Database-per-tenant is justified by one requirement that the shared model can't satisfy: regulated isolation (HIPAA single-tenant deployments, EU customers requiring distinct DSN per data subject group, government contracts). Performance is rarely the answer — properly indexed team_id columns scale into the high millions of rows per tenant on a single Postgres instance.

If you do need database-per-tenant, the cleanest pattern in Laravel 13 is a tenant connection that's reconfigured per-request inside the same BindCurrentTeam middleware:

public function handle(Request $request, Closure $next): Response
{
    $team = $request->route('team');
    abort_unless($team instanceof Team && $request->user()->belongsToTeam($team), 404);

    $this->current->set($team);

    config([
        'database.connections.tenant' => [
            ...config('database.connections.pgsql'),
            'database' => "tenant_{$team->id}",
        ],
    ]);
    DB::purge('tenant');

    return $next($request);
}

Models that live on the tenant connection set protected $connection = 'tenant'. Migrations get a separate path: database/migrations/tenant, run inside an artisan command that loops every team. Workers read the team ID from the job payload via TenantAware and reconfigure the connection at the top of handle(). It works. It's also five times the operational overhead of the shared model — separate migrations to run, separate backups, separate connection pools. Don't pay that price unless you have to.

stancl/tenancy is still the right choice if you go this route — it handles connection switching, cache scoping, queue isolation, and migrations across hundreds of tenant databases. The Laravel 13 starter kit teams compose with it cleanly: the URL gives you the team, the package gives you the database. They're not competing primitives.

Advanced patterns and edge cases#

Personal teams and the "no team" state. Every user gets a personal team on registration (personal_team = true). This means there's never a "user without a tenant" state — even solo users have a team. It also means logout/login flows have to land in /teams/{personal-slug}/dashboard, not a global /dashboard. Guard against the personal-team-only assumption: solo users will eventually want to invite a teammate, and the personal team should support that.

Team transfer and ownership change. Transferring ownership is a two-step write: change teams.owner_id, then update the pivot's role to owner for the new user (and demote the old owner). Wrap it in a transaction and a policy check. Add an audit log entry — ownership changes are exactly the thing customers ask about three months later.

Soft-deleting teams. If a team is soft-deleted but the global scope on Project only checks team_id, you'll keep returning projects for deleted teams. Either join teams and check deleted_at, or add a denormalised team_active flag the trait reads.

Cross-tenant sharing. Shared resources (a public template library, a marketplace) need an explicit nullable team_id and a separate scope. Resist the temptation to use team_id = 0 or a sentinel — null is unambiguous and indexes correctly.

Caching. Cache keys must include the tenant ID. Cache::remember('projects', ...) is a leak waiting to happen. Use a tenant-aware cache repository or namespace every key: "team.{$current->id()}.projects". Same for Pennant feature flags — flag a feature for a team, not for a user, when the feature is tenant-scoped.

Per-tenant background scheduling. If you need scheduled tasks per-tenant (a 9am digest in each team's timezone), keep them out of routes/console.php. Dispatch a single coordinator job that fans out — read the team list inside the job, dispatch one tenant-aware child job per team. Schedules in console.php run with no team context.

Testing this#

Multi-tenant tests follow one rule: every assertion about isolation must be a negative assertion — the request from Team A should not see Team B's data. Pest 4 makes this clean. Set up a base test that creates two teams and acts as a member of one:

use App\Models\{Project, Team, User};

beforeEach(function () {
    $this->teamA = Team::factory()->create();
    $this->teamB = Team::factory()->create();
    $this->user = User::factory()->create();
    $this->teamA->members()->attach($this->user, ['role' => 'admin']);
});

it('lists only the current team projects', function () {
    Project::factory()->for($this->teamA)->count(3)->create();
    Project::factory()->for($this->teamB)->count(5)->create();

    actingAs($this->user)
        ->get("/teams/{$this->teamA->slug}/projects")
        ->assertOk()
        ->assertViewHas('projects', fn ($projects) => $projects->count() === 3);
});

it('returns 404 for a team the user does not belong to', function () {
    actingAs($this->user)
        ->get("/teams/{$this->teamB->slug}/projects")
        ->assertNotFound(); // Never 403 — leak avoidance.
});

it('blocks cross-team model access by id', function () {
    $other = Project::factory()->for($this->teamB)->create();

    actingAs($this->user);
    app(CurrentTeam::class)->set($this->teamA);

    expect(fn () => Project::findOrFail($other->id))
        ->toThrow(ModelNotFoundException::class);
});

Add a Pest architecture test (covered in Pest architecture testing for Laravel) that pins the rule into CI: every model in App\Models that has a team_id column must use the Tenant trait. Architecture tests catch the case where a developer adds a new tenant model six months later and forgets the scope.

Common mistakes#

The recurring failure modes from production code reviews and Laracasts threads:

  • Returning 403 instead of 404 for non-member access. This leaks the existence of teams to anyone with a slug guesser. Always 404.
  • Using current_team_id on the user model. This was the Jetstream pattern and it's exactly what the new starter kit moved away from. Don't bring it back.
  • Reading CurrentTeam inside a job without TenantAware. Either null (best case) or a stale team from a previous request (worst case). The middleware exists for a reason.
  • Forgetting to scope queued mailables. Mail::to($team->members)->send(new Digest) reads the members list at queue time on the worker — without TenantAware, the global scope on related models leaks. Pass IDs and re-resolve.
  • Indexing too late. The team_id index isn't optional. Add it on the migration, not after the first slow-query alert.
  • Putting cross-tenant admin tools behind the same global scope. Build a separate admin panel that explicitly bypasses the scope — don't try to flip a "super admin" flag on a regular user inside the tenant context.
  • Exposing internal team IDs in URLs. Use the slug. IDs invite enumeration; slugs at least force a guess.

Wrapping up#

You now have a Laravel 13 multi-tenant foundation that matches what teams like Linear, Vercel, and Stripe ship: URL-bound tenants, scoped queries by default, isolated jobs, role-aware policies, and tests that prove the isolation. Shared database is the right starting point; database-per-tenant is the lever to pull only when compliance demands it.

Three places to take this next. First, layer real-time updates with Laravel Reverb broadcasting — tenant channels naturally namespace by teams.{id}. Second, expose a multi-tenant API with Laravel 13 JSON:API resources — the same team_id scope works on resource collections. Third, harden the queue side with the Laravel queues production guide so per-tenant retries, monitoring, and failures don't bleed across tenants.

Multi-tenancy isn't a feature — it's a discipline. Bind the team once, scope by default, test the negative case, and trust the framework to do the rest.

FAQ#

How does multi-tenancy work in Laravel 13?

Laravel 13's first-party multi-tenancy lives in the starter-kit teams scaffolding. The active team is encoded in the URL (/teams/{team:slug}/...), bound via implicit model binding, and stored in a request-scoped CurrentTeam singleton by a middleware. Every tenant-owned Eloquent model uses a Tenant trait that adds a global scope on team_id, so application-level queries are filtered automatically. Database isolation is shared-by-default, with database-per-tenant available as an opt-in for regulated workloads.

What is the difference between URL-based and session-based team switching?

Session-based team switching — the old Jetstream pattern — stored the active team in users.current_team_id, a session-bound integer. It broke as soon as a user opened two browser tabs, because both tabs shared the session and would silently mutate the wrong team's data. URL-based switching puts the team slug in the URL, so each tab carries its own tenant context, links shared between users land in the correct team, and the framework can resolve the team via implicit model binding without any session lookup.

How do I scope queries to a team in Laravel?

Add a team_id column to every tenant-owned table, write a Tenant trait that registers a global scope on the model, and have the global scope apply where('team_id', $current->id()) using the request-scoped CurrentTeam singleton. Stamp team_id automatically inside a model creating event so writes are also scoped. To bypass the scope intentionally — for an admin tool, for example — call Model::withoutGlobalScope(TenantScope::class) explicitly so it shows up in code review.

Should I use one database or multiple databases for multi-tenancy?

Default to a shared database with team_id foreign keys on tenant tables. It's cheaper, simpler to back up, and supports cross-tenant analytics. Reach for database-per-tenant only when you have a hard regulatory or contractual isolation requirement — HIPAA single-tenant, regional data residency, or government contracts. Performance is almost never the deciding factor: properly indexed shared tables scale into the high millions of rows per tenant. If you do go multi-database, stancl/tenancy composes well with Laravel 13's URL-team primitives.

How do I handle queued jobs in a multi-tenant Laravel app?

Use a TenantAware job middleware that accepts the team ID at dispatch and re-binds CurrentTeam before the worker calls handle(). Always pass IDs in the job payload, never models — IDs force a fresh query so the global scope catches them, while serialised models can bypass scoping at hydration time. Wrap the middleware logic in a try/finally that clears the bound team after the job runs, so the next job on the same worker doesn't inherit stale context.

How do I test multi-tenant features in Laravel?

Create at least two teams and one user attached to a single team in your test setup, then write assertions that prove cross-tenant data isolation. Hit a team-scoped route as the user and assert it returns 404 — never 403 — for the team the user doesn't belong to. Test that Model::findOrFail($id) on another tenant's record throws ModelNotFoundException because the global scope filters it out. Add a Pest architecture test that requires every model with a team_id column to use the Tenant trait, so future developers can't accidentally bypass scoping.

Can I migrate an existing Laravel app to URL-based teams?

Yes, but plan it as a phased migration. Start by adding a team_id column to existing tenant-owned tables (nullable initially), backfill the column from users.current_team_id, then add NOT NULL and the index in a second migration. Add the Tenant trait incrementally, model by model, and deploy each one with feature-flagged routes that read both the old session team and the new URL team during the transition. Once every model is scoped and every route lives under /teams/{team:slug}/..., drop current_team_id and remove the session-based code path.

How do I handle invitations to a team in Laravel 13?

Persist invitations in a team_invitations table with email, role, token, and expires_at columns. Send a temporary signed URL pointing at an acceptance route — URL::temporarySignedRoute('team.invitations.accept', $expires, [...]) — and validate the signature on the accept controller. On accept, attach the user to the team's members() relation with the stored role inside a transaction, then mark the invitation as accepted. For account-required invitations, gate the accept route behind auth; for signed-link invitations, redirect unauthenticated visitors to register first and replay the URL after sign-up.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.