architecture

CQRS in a Laravel + GraphQL stack: what we split and why

Why plato-next splits reads and writes across GraphQL Yoga + Laravel, what each side owns, how the Prisma schema bridges them, and where the complexity is actually worth it.

The architecture of plato-next is a Turborepo monorepo with three main applications: a Next.js frontend, a GraphQL read API (Yoga + Pothos), and a Laravel write API. The CLAUDE.md in the repo summarizes it in one line: “GraphQL handles GET/LIST operations; Laravel handles POST/PUT/DELETE operations.”

That’s CQRS. Not in the full event-sourcing, projection-rebuilding, eventual-consistency sense, in the practical sense of separating read models from write models because they genuinely benefit from different tooling.

This post is about how that split works in practice, what the Prisma schema does to connect the two sides, and what it cost.

The v1 problem

plato-next is a rewrite of Plato’s original web app, which was a Next.js frontend talking directly to Laravel REST endpoints.

The original architecture worked until the app grew large enough that the read and write concerns started pulling in opposite directions.

The read concerns: the frontend needed flexible data shapes. A single page might need customer data, their recent trail, the assigned AO (account owner), and the open support tickets, all aggregated. In v1, that meant either a fat endpoint that assembled all of it (slow, rigid) or multiple round trips that the frontend had to stitch together (chatty, complex). GraphQL is the natural answer to this shape of problem.

The write concerns: creating a trail, assigning an account owner, processing a payment, triggering a workflow, these involve validation, authorization, Eloquent model relationships, Horizon queues, and domain logic that’s already been built out in Laravel over years. Moving writes to GraphQL wouldn’t have simplified anything; it would have meant rebuilding the write infrastructure in a different layer.

So the decision was to stop pretending both sides want the same architecture. Reads get GraphQL. Writes get Laravel.

The read side: GraphQL Yoga + Pothos

GraphQL Yoga is the server runtime. Pothos is the schema builder, it lets you define the GraphQL schema in TypeScript with full type inference, tied to the Prisma schema through the Prisma plugin.

Here’s what a resolver looks like in practice (apps/graphql/src/allocation.ts):

const AllocationTeamGroup = builder.prismaObject("AllocationTeamGroup", {
  fields: (t) => ({
    id: t.exposeInt("id"),
    uuid: t.exposeString("uuid"),
    name: t.exposeString("name"),
    groups: t.expose("groups", { type: "Json" }),
    teams: t.field({
      type: [AllocationTeam],
      resolve: async (group) => {
        const uuids = group.groups as string[];
        if (!uuids || uuids.length === 0) return [];
        return prisma.allocationTeam.findMany({
          where: { uuid: { in: uuids } },
          orderBy: { name: "asc" },
        });
      },
    }),
  }),
});

builder.queryFields((t) => ({
  allocationTeamGroups: t.prismaField({
    type: [AllocationTeamGroup],
    resolve: async (query, _root, _args, ctx) => {
      requireAuth(ctx);
      return prisma.allocationTeamGroup.findMany({
        ...query,
        orderBy: { name: "asc" },
      });
    },
  }),
}));

builder.prismaObject("AllocationTeamGroup", ...) creates a GraphQL type from the Prisma model. The field types are inferred from the Prisma schema, if you try to expose a field that doesn’t exist, TypeScript tells you at compile time. The ...query spread in the resolver is Pothos’s mechanism for translating the GraphQL field selection into Prisma’s include/select, the resolver doesn’t need to build that manually.

The teams field is a custom resolver that loads related teams from another model. This is where GraphQL reads become useful: instead of a REST endpoint that returns a fixed JSON shape, the client can request exactly the fields it needs, and nested relationships are resolved without additional round trips.

The write side: Laravel REST

Writes stay in Laravel. The AllocationController in apps/api/app/Http/Controllers/Allocation/AllocationController.php handles allocation rule management:

public function createRule(Request $request, string $type)
{
    $v = Validator::make($request->all(), [
        'name'            => 'required|string|max:255',
        'priority'        => 'required|integer',
        'output'          => 'required|string',
        'fallback_output' => 'nullable|string',
        'skip_max_cap'    => 'sometimes|boolean',
        'jump_rule'       => 'sometimes|boolean',
    ]);
    if ($v->fails()) {
        return Response::badRequest('Validation failed', $v->errors()->messages());
    }

    $rulesKeys = config("allocation.rules_keys.{$type}", []);
    $conditions = [];
    foreach ($rulesKeys as $key) {
        $conditions[$key] = $this->parseConditionField($request, $key);
    }

    $rule = AllocationRule::create([
        'name'    => $request->input('name'),
        'type'    => $type,
        'rules'   => json_encode($conditions),
        'output'  => $request->input('output'),
        // ...
    ]);

    return Response::created($rule);
}

This is standard Laravel, Validator, Eloquent, config-driven business logic, response helpers. The write path has access to everything the Laravel ecosystem provides: Sanctum authentication, Horizon-managed queues for async work, Octane for performance, Laravel’s event system for side effects.

Moving this to GraphQL mutations would gain nothing. The write logic is not a data retrieval problem, it’s a business process problem. Laravel is where that logic belongs.

The bridge: Prisma and Eloquent on the same schema

The thing that makes the split work without creating two separate understandings of the data is the Prisma codegen setup (covered in more detail in the previous post). The Prisma schema generates both the TypeScript Prisma client (used by the GraphQL API) and Eloquent models (used by Laravel):

packages/database/prisma/schema.prisma
  → packages/database/src/generated/client  (Prisma Client, TypeScript)
  → packages/eloquent/src/Models             (Eloquent models, PHP)

Both sides talk to the same PostgreSQL database through different ORM layers that were generated from the same schema definition. When a new column is added to the AllocationRule model:

  1. Prisma schema updated, turbo db:generate run
  2. TypeScript Prisma client picks up the new field automatically
  3. Generated Eloquent model’s $fillable array includes the new field
  4. GraphQL resolver can expose the new field via t.exposeString("new_field")
  5. Laravel controller can use $request->input('new_field') in validation and persistence

No manual sync required between the TypeScript layer and the PHP layer.

Authentication across two services

One practical challenge: a user authenticated by Laravel’s Sanctum needs to access the GraphQL API without re-authenticating.

The approach: Laravel issues a short-lived JWT on login. The GraphQL API validates that JWT independently using the same secret. The user’s session carries one token that works across both services.

// GraphQL context setup
const context = async ({ request }: { request: Request }) => {
  const token = request.headers.get("Authorization")?.replace("Bearer ", "");
  const currentUser = token ? await validateJWT(token) : null;
  return { currentUser, prisma };
};

The currentUser in context contains the user’s ID and roles. Resolvers check this via requireAuth(ctx) before accessing data. Authorization is enforced in the GraphQL layer for reads, and in Laravel middleware for writes.

Where the complexity lands

The benefit of the split is real. The GraphQL API handles complex aggregated reads without requiring fat endpoints or client-side stitching. The Laravel API handles write workflows cleanly without being constrained by the read model’s shape.

The cost is real too.

Two services mean two deployments, two CI pipelines, two sets of logs to watch, two places a bug might live. The frontend developer needs to know: is this a query (GraphQL) or a mutation (REST)? That distinction is conceptually clean but it adds mental overhead that didn’t exist when there was one API.

The shared Prisma schema adds a new concern: turbo db:generate has to run when the schema changes, and if it doesn’t, the TypeScript and PHP layers can drift until someone notices. We added a CI check for this (generate and check the diff), but it’s operational complexity that has to be maintained.

The services need to agree on auth token format and validation. If the JWT library behavior diverges between the Node and PHP implementations, you get mysterious auth failures that are hard to debug.

These aren’t dealbreakers. They’re the real costs of the architecture, and I think they’re worth paying for a system the size of plato-next. But they’re not free, and I’d be cautious about recommending this pattern to a team that doesn’t have a clear v1 pain point to solve. The split is worth it when you’re fighting a real problem. As an upfront architectural decision for a new system, it’s a lot of complexity to take on before you know what the actual bottlenecks are.

We knew what the bottlenecks were from the original Plato. That’s why the split felt justified.