← cd ../articles

Laravel Restify on Cloudflare Workers: a REST kit with models, repositories and policies

19 October 2026 · 9 min read

  • TypeScript
  • Cloudflare Workers
  • Laravel Restify
  • API design
share: LinkedIn X

We run several independent APIs on Cloudflare Workers: identity, billing, payments, telephony, and a CRM. Each started the same way: Hono for routing, plain SQL over D1, and a list endpoint someone wrote by hand.

By the fifth Worker they had drifted apart. Paging was ?page= in one and ?offset= in another. The response envelope differed, tenant scoping was a WHERE account_id = ? each query had to remember, and every API had its own idea of a validation error.

We pulled the repeated part into a small internal kit. Then we made one decision that shaped everything after it: the kit would be inspired by Laravel Restify. Restify turns a Laravel app’s models into a full REST API from one repository class per resource: CRUD, search, filters, sorting, policies and actions, with almost no controller code. The team uses it every day on our main product, so instead of inventing a vocabulary, the kit borrows Restify’s, and Laravel’s around it, on purpose. It uses the same names, the same shapes, and the same folder layout in the apps that use it. A teammate put it best: the kit should be our vendor/illuminate for Workers.

Version one: four small classes

The first version followed a Controller, Service, Repository layering. A resource was four short classes, and the kit mounted GET|POST /v1/invoices and GET|PUT|PATCH|DELETE /v1/invoices/{id} with one { ok, data, meta } envelope, per-field 400s, managed timestamps and OpenAPI entries.

Three decisions from that version survived everything that came later.

List parameters are declared, never parsed. ?q= only matches the columns a resource declares as searchable, and ?sort= only accepts declared names. Both are checked against the allowlist and the column list before an identifier reaches SQL, so no query parameter can be spliced in.

No ORM, one invariant. We dropped the ORM in the second release for a ~90-line SQL builder whose one rule is that an interpolated value always becomes a bound ?. Identifiers are the only thing spliced, always quoted, and always validated against the declared columns first.

Tenancy is a parameter, not a strategy. The first version resolved the tenant through a host-wide “tenancy port”. It guessed from the credential and, once, guessed the wrong parameter: a valid scope carried the wrong tenant id all the way into a query. The replacement makes tenantId the first argument of every by-key method on a tenant repository, so find(id) simply doesn’t compile. Whether a resource is tenant-scoped became a choice of base class.

Where one class was doing two jobs

Rebuilding a CRM on the kit showed the limit of version one. A repository was one class doing two jobs: the database layer (table, columns, casts, soft deletes, relations) and the API layer (which columns a list shows, what a client may write, search and filters). Overrides restated the scope and soft-delete rules, so a deleted row could be hidden on one list path and leak on another. Responses were assembled by subtraction (all columns, minus hidden ones), so a forgotten strip was a leak.

Laravel had already solved this by splitting the two jobs: Eloquent owns the data, and a Restify repository owns the API. So the kit grew a model layer that does the same.

Here is the whole path one request takes, with the Laravel piece each layer mirrors:

How a request flows through the kit A client calls the Worker. Inside the Worker, the Hono router mounted by repositoryModule passes the request to the Auth facade and the route gate, then to the repository, which validates and fills the fields. One call per operation crosses to where the data lives: the model saves, the policy is asked with the loaded record, observers run, and everything is written in one atomic batch through the Connection port to either D1 or a tenant's Durable Object SQLite. Each layer is labelled with its Laravel equivalent. Client · HTTP + JSON GET /v1/invoices?status=sent Worker Router (Hono) five routes mounted by repositoryModule() routes/api.php Auth facade · route Gate Auth.user() · policy viewAny / create middleware · Gate Repository fields() validate + fill · allowlisted output Restify Repository one call per operation Where the data lives Model findOrFail · fill save · isDirty Eloquent Policy · Observers update(user, invoice) creating / updating Policy · Observer Connection port one atomic batch values always bound DB connection D1 · platform data Tenant DO · SQLite
One request through the kit. The italic labels name the Laravel piece each layer mirrors.

The model: Eloquent’s job, and only that

A model declares the table and nothing about HTTP:

export class Invoice extends Model<typeof Invoice> {
  static readonly table = 'invoice'
  static readonly timestamps = 'ms'
  static readonly softDeletes = 'deleted_at'

  static readonly columns = {
    id: column.id('inv'),
    customer_id: column.string(),
    number: column.string(),
    total: column.integer(),          // minor units
    status: column.enum(['draft', 'sent', 'paid']).default('draft'),
    paid_at: column.timestamp('ms').nullable(),
  }

  static readonly relations = {
    customer: belongsTo(() => Customer, 'customer_id'),
  }
}

As in Eloquent, an instance is the record. Invoice.in(scope).findOrFail(id) returns an Invoice, invoice.status is typed from the column, invoice.customer() is a typed relation, and invoice.isDirty('status') works the way a Laravel developer expects. new Invoice() is an empty, unsaved record. Getting there took a real refactor. An earlier cut had a separate record type, and record instanceof Invoice was false, which surprised everyone who had used Eloquent.

The repository: Restify’s job, with no controllers

In Laravel Restify you rarely write controllers. The repository is the API, and the framework generates the endpoints. The kit does the same:

export class InvoiceRepository extends Repository<Invoice> {
  protected readonly model = Invoice
  protected readonly policy = InvoicePolicy

  protected readonly filters = [match('customerId', 'customer_id'), match('status', 'status')]
  protected readonly orderBy = [desc('created_at')]

  /** The whole response, in wire order; writable where a field isn't readonly. */
  fields() {
    return [
      id(),
      field('customer_id').rules(z.string()).required(),
      field('number').rules(z.string().max(32)).required(),
      field('total').rules(z.number().int().nonnegative()).required(),
      field('status'),
      field('paid_at').readonly(),
    ]
  }
}

One fields list declares both what the API shows and what it accepts, so there is no request schema to keep in sync with a response mapper. The response is built by allowlist: a column not in fields is never sent. repositoryModule(InvoiceRepository) mounts the five routes. A create is Restify’s own store() in two steps, fillFields then save, made public: Repository.fill(request, body) validates and fills a new model, then model.save() writes it. The same pair works in a queue consumer or an import action, not just behind POST /.

Policies, observers and an Auth facade

The rest of the Laravel toolkit came along with the same names.

Policies look exactly like Laravel’s:

export class InvoicePolicy implements ResourcePolicy<Invoice, User> {
  viewAny(user: User) { return user.isMember() }
  create(user: User) { return user.isContributor() }
  update(user: User, invoice: Invoice) { return user.isContributor() && invoice.status !== 'paid' }
  delete(user: User, invoice: Invoice) { return user.isAdmin() }
}

The record is a required parameter, as in Laravel’s update(User $user, Invoice $invoice). The kit asks the policy once, where the record is loaded, so there’s no check-then-write gap between “may I?” and the write.

Observers stamp what a write stores beyond what the client sent, inside the same atomic batch:

export class InvoiceObserver implements ModelObserver {
  updating(invoice: Invoice, context: ObserverContext) {
    if (!invoice.isDirty('status')) return
    invoice.fill({ paid_at: invoice.status === 'paid' ? context.now : null })
  }
}

Auth and the Gate are Laravel’s too: an app’s User extends the kit’s base User, Auth.user() reads the current principal from the captured request, and an action calls Gate.authorize('import', Contact). We didn’t invent a Principal or a GenericUser. The rule was “keep Laravel’s names”, and it held.

Actions follow Restify’s three kinds, standalone, show and index, for things like “import contacts from CSV” or “mark these as paid”.

The apps mirror a Laravel project’s layout too: Models/, Policies/, Observers/, Rules/, Actions/, and an EventServiceProvider that registers observers. Someone who knows Laravel can open the CRM and find their way around in minutes, and that was the point.

Where Workers said no

Borrowing Laravel’s shape doesn’t mean pretending to be PHP. A few places forced a deviation, and each one is written down rather than hidden.

  • Data can live inside a Durable Object. The CRM keeps each tenant’s data in SQLite inside that tenant’s own Durable Object. So the controller, auth and validation stay in the Worker, and the repository’s write runs where the data is, one call per operation, with results crossing back as plain data. A database port with two drivers (D1 and the object’s SQLite) keeps the same model code working on both.
  • Bound parameters are capped. D1 and Durable Object SQLite allow roughly a hundred bound parameters per statement, so an index action over a large selection binds its ids as one JSON parameter (json_each) rather than a hundred ?s.
  • Actions are compiled once per isolate. Restify builds actions per request. On Workers the kit compiles them once and asks the visibility callbacks per request instead.

Every one of these went through a short decision record that compared three candidate designs before picking one. That habit mattered more than any single design: it’s why the kit could change this much in three months without the five APIs on it breaking.

What it cost

A shared kit means a breaking release touches every API. We keep that manageable with a small public surface (a few entry points, everything else explicitly internal), loud breaking changes, and an upgrade guide per release.

What we got: a new resource is a model, a repository and a policy. Every API pages, filters, authorizes, errors and documents itself the same way. And a Laravel developer reads the code as if it were Laravel. That last part turned out to be the real win: a familiar shape is something every new teammate already knows. If you write Laravel and you’re moving APIs to Workers, I’d steal Restify’s design before inventing your own.

Found this useful? Pass it on.

share: LinkedIn X