Case story Laravel developers building AI features, MCP servers, and agent tooling

How I Built Swarm Platform on Laravel

13 min read Updated Jul 12, 2026

Swarm Platform is in closed beta. What follows is how it's built. If you want the product itself — a shared memory your AI tools read from and write to — request access, invite-only while it's small.

Our developers work in Codex. Our business development, sales, and marketing people work in Claude Code. Some of the engineers live in PHPStorm, some in Cursor. On any given day the company is running four different AI surfaces — and not one of them shared a brain with the others.

So the same context got rebuilt at every boundary. A developer and an agent would work out, in Codex, exactly why the billing reconciliation runs the way it does. The next morning someone in Claude Code would ask a fresh agent the same question and get a confident, plausible, wrong answer — because the reasoning lived in a Codex session that had closed. The decision existed. It just had nowhere to live that the rest of the company, or the rest of the tools, could reach.

That is the problem Swarm Platform solves: a shared memory that every AI tool and every person on the team reads from and writes to. It is in closed beta, and it is Laravel end to end. This is how I built it — and, more usefully, why each of the big decisions went the way it did.

#What we tried first

Before writing a line of it, we tried the three things you are already thinking of. Each one works. Each one works inside a single silo and dies at the edges.

"The tools already have memory." They do — per tool, per account, opaque, and un-shareable. Codex's memory never reaches the marketer in Claude Code. Claude's project memory never reaches the engineer in Cursor. It is memory for a seat, not for a project or a company. The moment your team uses more than one tool, per-tool memory is a set of disconnected notebooks.

"Just pass the context." Paste the brief, paste the constraints, paste the decision at the top of every session. That is a tax you pay by hand, every time, and it still does not survive the window, cross a tool, or reach another person. It scales to zero.

"Write it to the repo." This is the strongest objection, so it is worth being honest about: it half-works. A CLAUDE.md, an AGENTS.md, a docs/decisions folder — for developers, that is real and useful. But it fails on three edges at once. Sales and marketing never open the repo, so half the company is excluded by construction. A repo stores code and prose, not "we decided X because Y, and it supersedes Z" as a retrievable, ranked unit — so even for engineers it goes stale and unread, because nobody reliably pulls the right file at the right moment. And it is scoped to one checkout on one machine, while the work spans many repos and more machines than I want to count.

The pattern underneath all three is the same. The repo is a developer silo. Per-tool memory is a seat silo. Passing context is a session silo. What we needed was a layer that spanned all of them.

#Why the answer had to be MCP

Here is the constraint that made the design obvious. If the pain is that context does not cross tools, then the fix has to be something all those tools already speak. We were not going to convince the sales team to leave Claude Code, or the engineers to leave Codex and Cursor. The memory had to meet each of them where they already were.

There is exactly one thing Claude Code, Codex, and Cursor all speak: the Model Context Protocol. MCP is not interesting here because it is new. It is interesting because it is the lingua franca — the single integration surface that reaches every tool the company already runs. Build the memory once, behind MCP, and all four surfaces get it for free.

A cold chat, connected to the shared channel, answering the project's state from memory A brand-new chat, no brief pasted. The first message pulls the channel's memory and answers from it.

So the shape was set: a hosted service that stores typed, durable memory and exposes it as an MCP server any compliant client can connect to. The only question left was what to build it in. We build in Laravel, so the honest version of that question was: how much of this can Laravel do for us? The answer turned out to be almost all of it.

#Why Laravel MCP

The memory is not a standalone thing. It has workspaces, channels, members, invites, an authorization model, a billing story, and a full Inertia + React web app — an ordinary Laravel application. The tempting mistake would have been to stand up a separate Node MCP service next to it and spend the rest of my life keeping two worlds in sync: two identity models, two sets of validation, two deploys.

laravel/mcp let me avoid that entirely. The MCP server is the Laravel app. The whole transport is one route:

// routes/ai.php
use App\Mcp\Servers\SwarmServer;
use Laravel\Mcp\Facades\Mcp;

// Streamable HTTP, behind Passport. Identity is the bearer token on every
// request — there is no stdio transport and no static token in a config file.
Mcp::web('mcp/swarm', SwarmServer::class)
    ->middleware(['throttle:mcp', 'auth:api', 'account.active']);

// OAuth 2.1 discovery + Dynamic Client Registration. Compliant clients
// self-register and run the authorization-code + PKCE flow themselves.
Mcp::oauthRoutes();

That is the entire wiring. Standard Laravel middleware guards it — my own throttle, Passport's auth:api, and an account.active check that boots a suspended account on its very next request, on the web and MCP paths alike. There is no bespoke auth layer for the AI surface; it is the same guard stack as the rest of the app.

An early decision this made easy, and that I would defend hard: we dropped the stdio token. The common way to run an MCP server is locally, over stdio, authenticated by a token you paste into a config file. For a hosted, multi-tenant, multi-tool product that is a non-starter — a shared secret in a dotfile is not identity. Mcp::oauthRoutes() publishes the OAuth 2.1 metadata and a Dynamic Client Registration endpoint, all backed by Passport, so each client self-registers and completes a proper auth-code + PKCE flow. Identity is always the bearer token on the request, tied to a real user in a real workspace. No token to copy, no token to leak.

The heterogeneity that started this whole project shows up, literally, in config. Here is the redirect allowlist:

// config/mcp.php
'redirect_domains' => [
    'https://claude.ai',
    'https://claude.com',
    'https://chatgpt.com',   // ChatGPT / Codex custom-connector callback
    'http://localhost',
],

'custom_schemes' => ['cursor', 'vscode'], // RFC 8252 native desktop clients

Four tools, one server. Cursor and VS Code register through private-use URI schemes; the web clients through HTTPS callbacks. We deliberately do not allow * — an open redirect allowlist is the main phishing surface of a public OAuth server — so the pain point and its security consequences live in the same twenty lines of config.

Inside the server, a tool is a thin Laravel class. No framework to learn on top of the framework — validation, dependency injection, and structured responses are the ones you already use:

#[Name('create_record')]
#[Description('Create a channel-visible intelligence record.')]
class CreateRecordTool extends Tool
{
    public function handle(Request $request, McpAuthenticator $auth, ContextBridgeService $contexts): ResponseFactory
    {
        $context = $auth->authenticate($request);

        $validated = $request->validate([
            'channel_key' => ['required', 'string'],
            'type'        => ['required', Rule::in(IntelligenceRecord::types())],
            'title'       => ['required', 'string', 'max:255'],
            'what'        => ['required', 'string'],
            'why'         => ['required', 'string'],
        ]);

        $channel = $contexts->findScopedChannelByKey($context, $validated['channel_key']);
        $record  = $contexts->createRecord($context, $channel, $validated);

        return Response::structured([
            'message' => 'Intelligence record created.',
            'record'  => $contexts->recordPayload($record),
        ]);
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'channel_key' => $schema->string()->required(),
            'type'        => $schema->string()->enum(IntelligenceRecord::types())->required(),
            'title'       => $schema->string()->required(),
            'what'        => $schema->string()->description('The durable statement.')->required(),
            'why'         => $schema->string()->description('Why it is useful.')->required(),
        ];
    }
}

The handle() method is a controller I already know how to write. The schema() method is the tool's contract to the model. There are around thirty of these — capture, promote, supersede, search, handoff, dispatch — each a small, testable class registered on the server.

#The server is just a class (with one gotcha)

The server itself is a plain PHP class. Tools, resources, and prompts are properties; the server's instructions are an attribute:

#[Name('Swarm Server')]
#[Version('0.0.1')]
#[Instructions(SwarmServer::RITUAL)] // the ritual every client reads on connect
class SwarmServer extends Server
{
    // tools/list paginates at 15 by default, and most clients only render the
    // first page — so a tool past position 15 silently vanishes from the model's
    // list. Raise the page size above the whole surface.
    public int $defaultPaginationLength = 100;

    protected array $tools = [
        CaptureContextTool::class,
        GetContextForInjectionTool::class,
        CreateRecordTool::class,
        SupersedeRecordTool::class,
        SearchTool::class,
        // …about thirty in all
    ];
}

Two things worth stealing. First, #[Instructions] ships the "how to use this server" text in-band, so the moment a client connects it knows the ritual — pull context at the start of a task, checkpoint at the end — with no project file required. Second, that defaultPaginationLength. The default is 15, tools/list paginates, and most clients only read the first page. When the toolset grew past fifteen, a tool at the back simply stopped existing as far as the model was concerned. It took an embarrassingly long time to notice, and there is now a test that keeps the page size above the tool count.

#The records: typed memory, in Postgres

Memory worth trusting cannot be a pile of chat logs. The core table is deliberately small and deliberately opinionated — every unit of memory is a typed record with a reason attached:

Schema::create('intelligence_records', function (Blueprint $table) {
    $table->id();
    $table->foreignId('channel_id')->constrained()->cascadeOnDelete();
    $table->foreignId('promoted_from_session_id')->nullable()
          ->constrained('personal_context_sessions')->nullOnDelete();
    $table->string('type');       // decision | standard | fact | issue | insight | task | risk
    $table->string('title');
    $table->text('what');
    $table->text('why');
    $table->string('status')->default('active'); // active | provisional | disputed | resolved | archived
    $table->json('type_fields')->nullable();
    $table->timestamps();

    $table->index(['channel_id', 'status', 'type', 'created_at']);
});

Two design choices carry most of the weight. First, what and why are separate, required columns — a record is never just a statement, it is a statement and the reasoning that makes it useful. That is the exact thing the repo could not hold. Second, memory changes, so nothing is ever overwritten. When a decision is replaced, a new record is created with a supersedes edge back to the old one. Being superseded is a derived state, read from that edge — it is never written to the old row's status column. The old record is left exactly as it was; it simply drops out of what retrieval surfaces while the new truth takes its place, and the history — including why it changed — stays intact for the inevitable "wait, why did we change this?" A promoted_from_session_id links a record back to the working session it came out of, so provenance is never lost. It is all ordinary Eloquent — the discipline is in the schema, not in a framework.

#Retrieval: pgvector and Voyage, behind a seam

A memory you cannot search is a junk drawer. When a fresh session connects, it does not get the whole channel — it gets the relevant few, ranked by meaning against the task at hand. That means embeddings and a vector search, and this is where the hosting choice starts paying off, because the vector database was not a second piece of infrastructure. It was a column.

$isPostgres = DB::connection()->getDriverName() === 'pgsql';
$dimensions = (int) config('services.voyage.dimensions', 1024);

if ($isPostgres) {
    DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
}

Schema::create('context_embeddings', function (Blueprint $table) {
    $table->id();
    $table->string('embeddable_type');
    $table->unsignedBigInteger('embeddable_id');
    $table->string('model');
    $table->string('content_hash');   // re-embed only when the text actually changes
    $table->timestamps();
    $table->unique(['embeddable_type', 'embeddable_id']);
});

if ($isPostgres) {
    DB::statement("ALTER TABLE context_embeddings ADD COLUMN embedding vector({$dimensions})");
    DB::statement('CREATE INDEX context_embeddings_embedding_idx
        ON context_embeddings USING hnsw (embedding vector_cosine_ops)');
}

Postgres with pgvector gives you an approximate-nearest-neighbour index (HNSW, cosine distance) right next to your regular tables — no separate vector store to run, back up, and keep consistent with the source of truth. Retrieval is: embed the query, pull the nearest records by cosine distance, then rerank for precision. We use Voyage — voyage-3.5 for embeddings, rerank-2.5 as a cross-encoder over the candidates — and the whole of that lives behind an interface so it is swappable and, critically, testable:

$this->app->singleton(Embedder::class, function ($app) {
    $config = (array) config('services.voyage');

    // Tests and key-less runs get a deterministic fake — Voyage is never
    // hit in the suite. A real key gets the real client.
    return $app->runningUnitTests() || ! $this->hasVoyageKey($config)
        ? new FakeEmbedder($config['dimensions'] ?? 1024)
        : new VoyageEmbedder($config);
});

$this->app->singleton(VectorStore::class, function ($app) {
    // pgvector on Postgres; a keyword-only store everywhere else.
    return $app['db']->connection()->getDriverName() === 'pgsql'
        ? new PgVectorStore($app['db']->connection())
        : new NullVectorStore;
});

Three interfaces — Embedder, Reranker, VectorStore — with real implementations in production and fakes in the suite. The full test suite never calls Voyage and never needs a paid key, and it degrades gracefully: no key, or a non-Postgres connection, and retrieval falls back to keyword search instead of failing. A queued job does the embedding, keyed by that content_hash so unchanged text is never re-embedded, and the search only ever runs over records in channels the requesting user belongs to. Retrieval is membership-scoped before it is semantic.

#Two identity planes: Fortify for humans, Passport for agents

Humans and agents authenticate differently, on purpose. People sign in through Fortify with passkeys and two-factor. Agents present OAuth access tokens through Passport. The two never share a guard, and neither can borrow the other's authority. Live updates — a teammate's record landing, a session checkpointing — ride Laravel Reverb over WebSockets, scoped, like everything else, to workspace membership. The authorization boundary is the same in all four places context can move: HTTP, the MCP channel scope, broadcasting, and search.

#Steering is a decision, and it's an enum

The agents author records; humans steer what becomes shared canon. That steering is a single setting — capture mode — and I wanted the safe composition to be impossible to get wrong. It is a backed enum whose cases are ordered by aggressiveness, so resolving a user's default against any number of channel or workspace caps is just "take the strictest":

enum CaptureMode: string
{
    case Off = 'off';         // never checkpoint
    case Propose = 'propose'; // ask a human first
    case Auto = 'auto';       // checkpoint silently

    // Lower rank is stricter, so a single Off cap anywhere forces the session
    // private — one member on Auto can never out-share a channel capped at Propose.
    public static function resolve(self $userDefault, ?self ...$caps): self
    {
        $effective = $userDefault;

        foreach ($caps as $cap) {
            if ($cap !== null && $cap->rank() < $effective->rank()) {
                $effective = $cap;
            }
        }

        return $effective;
    }

    public function rank(): int
    {
        return match ($this) {
            self::Off => 0, self::Propose => 1, self::Auto => 2,
        };
    }
}

The enum also owns the guidance string the agent receives at the end of a session, so the runtime and the downloadable client skills render from one source and cannot drift. Private working context stays private until the resolved mode says otherwise.

#Why Laravel Cloud

Built by Berry is a small shop. There is no platform team, no one whose job is Kubernetes. So the deployment target had one requirement above all others: it had to let a couple of people run a real, multi-tenant SaaS without turning into an infrastructure company.

Laravel Cloud is why the retrieval section above is three code snippets instead of a war story. The managed Postgres supports the vector extension, so CREATE EXTENSION IF NOT EXISTS vector in a migration is the entire "we adopted a vector database" project. Reverb — driving those live updates — is a first-party citizen, not a WebSocket server I stand up and babysit. Queues drain the embedding jobs; the scheduler runs the maintenance passes. The concrete version of "why Laravel Cloud" is that the parts of this product that would normally each be their own operational headache — a vector DB, a realtime layer, a queue fleet — were configuration, not infrastructure. For a two-person team shipping to a closed beta, that is the difference between shipping and not.

#We build the product on the product

The most honest endorsement I can give Swarm Platform is that we could not have built it without it.

Built by Berry runs on it. This article, the marketing site, the product itself — all coordinated through Swarm channels the agents and the people read from and write to. When I open a fresh Claude Code session to work on the platform, the first thing it does is pull the channel's canon: the decisions we have made, the standards the code has to follow, the facts that are still true. When a session ends, the durable pieces get written back as records. The naming collision we hit last week, the reason a table is append-only, the choice to link a signup off-site instead of building a local form — those are not in my head or in a stale doc. They are records, retrieved on demand, by whatever tool the next person happens to be using.

Which is the whole point. The developer in Codex and the marketer in Claude Code are, finally, working out of the same memory.

#What isn't solved yet

I would rather tell you the edges than pretend there are none.

Capture is still a discipline. The agents author records and humans steer what becomes canon — but garbage in is still garbage out. A channel full of low-signal records retrieves worse than a small, well-curated one. Typed records and human-in-the-loop capture modes keep the signal high, but it is a practice, not a guarantee.

Retrieval is never "done." Ranking the relevant few is the hardest part, and the honest state is good-and-getting-better, not solved. We are working a learned scorer that tunes injection against what sessions actually found useful, rather than cosine distance alone.

Embeddings cost money. Every record embedded is a Voyage call, so we meter it — there is an embedding_spend_events table for exactly this — and re-embed only when a record's content hash changes. At scale, retrieval quality and retrieval cost are the same conversation, and we treat it that way.

Where it goes from here: more first-class clients as the MCP ecosystem grows, sharper ranking, and widening the beta once the second-session experience — the real payoff, when a project you set up yesterday recalls itself cleanly — lands reliably for the first cohort.

#Try it

Swarm Platform is in a closed, invite-only beta. If your team is spread across more than one AI tool — and if you are a Laravel shop, it almost certainly is — and you are tired of every fresh chat starting from zero, request access and tell us what you are building.

And if you want the framework underneath the agent work we do for clients, laravel-swarm is open source and separate — Swarm Platform is the memory; laravel-swarm is the orchestration. Different tools, same idea: the useful AI is the one that shows up where the work already lives.

Edit this article on GitHub