← cd ../articles

From Laravel to Cloudflare Workers: rebuilding Laravel Restify on Hono

19 October 2026 · 16 min read

  • Laravel Restify
  • Cloudflare Workers
  • Hono
  • TypeScript
share: LinkedIn X

We’re moving our backend from a Laravel monolith to Cloudflare Workers. The pieces split into independent services (identity, billing, payments, telephony, a CRM), and each one now runs as a Worker with Hono as the router.

Hono is excellent at what it does. It is also, deliberately, very little: a router and a handler function. There are no models, no validation layer, no policies, no conventions for pagination or errors. For three endpoints that’s a feature. For dozens of resources across five services, built by several people, it’s a problem waiting to happen.

This is the story of how we filled that gap: not by inventing a framework, but by rebuilding the one we already knew, Laravel Restify, on top of Hono.

What “just a handler” looks like at scale

Here is a typical list endpoint written straight on Hono:

app.get('/v1/invoices', async (c) => {
  const auth = c.get('auth')
  const page = Math.max(1, Number(c.req.query('page') ?? 1))
  const { results } = await c.env.DB
    .prepare('SELECT * FROM invoice WHERE account_id = ? ORDER BY created_at DESC LIMIT 20 OFFSET ?')
    .bind(auth.accountId, (page - 1) * 20)
    .all()
  return c.json({ data: results })
})

Nothing is wrong with it. Now multiply it by five verbs, by every resource, by five services, and by every developer’s personal taste. That’s what we had after the first few Workers:

  • paging was ?page= in one API and ?offset= in another,
  • one API returned { data }, another { ok, data }, a third a bare array,
  • tenant scoping was a WHERE account_id = ? each query had to remember,
  • validation errors had a different body everywhere,
  • and SELECT * quietly shipped columns nobody meant to expose.

None of it was hard. All of it was repeated, and every copy had its own bugs. We needed a framework on top of Hono, one that would keep five services consistent as they grew.

Why Laravel Restify

The team uses Laravel Restify every day on our main product. If you haven’t seen it: Restify turns a Laravel app’s Eloquent models into a complete REST API from one repository class per resource. That gets you CRUD, validation, search, sorting, filters, policies and actions, with almost no controller code.

class InvoiceRepository extends Repository
{
    public static string $model = Invoice::class;

    public static array $search = ['number'];
    public static array $sort = ['created_at', 'number'];
    public static array $match = ['status' => 'string'];

    public function fields(RestifyRequest $request): array
    {
        return [
            field('number')->rules('required', 'max:32'),
            field('total')->rules('required', 'integer'),
            field('status'),
        ];
    }
}

That class is the invoices API. We already knew its shape, its trade-offs and its edge cases. So instead of designing a new vocabulary for Workers, we made a deliberate call: rebuild Restify’s shape in TypeScript, on Hono, and adapt only where Workers force us to. A teammate put it best: the kit should be our vendor/illuminate for Workers.

The flow we started from

This is a request through Laravel Restify:

How a request flows through Laravel Restify A client calls the Laravel app. Restify registers the routes for every repository. Middleware authenticates the user and the Gate asks the policy. The repository validates the fields, applies search, sort and match, and fills the Eloquent model. The model saves, observers run, and everything goes through the single database connection. Client · HTTP + JSON GET /api/restify/invoices?status=sent Laravel app · one server Restify routes Restify::repositories([...]) · auto CRUD no controllers Middleware · Gate auth:sanctum · Policy::viewAny / store who may call Repository fields() · rules · $search $sort $match the whole API fillFields() → save() Eloquent Model casts · relations soft deletes Policy · Observers update($user, $model) creating / updating One database MySQL / Postgres one connection pool
Laravel Restify: one app, one database, and the repository is the API.

Everything runs in one app, against one database, with one connection pool. The repository is the API, Eloquent is the data, and the Gate and policies decide who may do what.

The flow we built

And this is the same request on Workers, through our kit:

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
The same request on Workers. The italic labels name the Restify or Laravel piece each layer mirrors.

The layers line up almost one to one. Two things changed: the router is Hono instead of Laravel’s, and the data might not be in one shared database. Some services use D1, and the CRM keeps each tenant’s data in SQLite inside that tenant’s own Durable Object. Here’s the full mapping:

Laravel RestifyOur kit on Workers
Restify::repositories([...])repositoryModule(InvoiceRepository) mounts the five routes on Hono
Repository::fields() with rules()fields() with Zod rules, one list for what the API shows and accepts
$search, $sort, $matchsearchable, orderBy, filters, declared and allowlisted
Eloquent ModelModel with columns, relations, timestamps, softDeletes
Policy and the GateResourcePolicy, Gate.authorize(), Auth.user()
Model observersModelObserver running inside the write’s atomic batch
fillFields() then save()Repository.fill() then model.save()
Restify actionsStandalone, show and index actions
DB connectionA Connection port with D1 and Durable Object SQLite drivers

A worked example: one invoice, four flows

Mapping tables are easy to agree with. The real test is a resource that’s used in several different ways at once. Take invoices, with the model, repository, policy and observer shown in the sections below, and follow four requests through it:

Four flows through the same invoice resource Columns are the layers: router, auth and gate, repository, model with policy and observers, and storage. Row one, a filtered list, passes through every layer and only reads. Row two, marking an invoice paid, is validated by the repository, checked by the policy with the loaded record, stamped by the observer and written in one atomic batch. Row three, a reminder action over many invoices, is gated by canSee, loads the selection in chunks and saves each model. Row four, a payment webhook on a queue, skips the router and the route gate, runs as a service actor, and uses the same model, observer and storage. Router Auth · Gate Repository Model layer policy · observer Storage 1 · List GET ?status=sent viewAny filters · q · include scoped select read 2 · Mark paid PATCH /inv_123 scope gate fill · validate update(user, invoice) updating → paid_at one atomic batch 3 · Remind all index action canSee · Gate load chunks handle(models) ids as one param 4 · Webhook queue · no HTTP skipped service actor skipped findOrFail · save same observer same batch
One resource, four flows. The model, observer and storage are shared by all of them; only the entry point differs.

1. A filtered list. A dashboard asks for sent invoices matching a number, with the customer included:

GET /v1/invoices?status=sent&q=INV-10&include=customer&sort=created_at&dir=desc

The route gate asks the policy’s viewAny. The repository turns status into a declared filter, q into a search over the declared searchable columns, and include=customer into one extra query for the relation instead of one per row. The model adds the tenant scope and the soft-delete condition, and the response is built only from fields(). No handler code was written for any of it.

2. Marking one invoice paid. The same resource, a write:

PATCH /v1/invoices/inv_123
{ "status": "paid" }

The repository validates status against its field rules and fills the model. The policy is asked update(user, invoice) with the loaded record, so “a paid invoice can’t be edited” is one line in the policy. The observer sees isDirty('status') and stamps paid_at. The row, the stamp and the action log entry are written in one atomic batch, so there’s no half-paid invoice if anything fails.

3. Sending reminders for many invoices. An index action, as in Restify:

export class SendRemindersAction extends Action<Invoice> {
  uriKey = 'send-reminders'

  async handle(request: ActionRequest, invoices: Invoice[]) {
    await Gate.authorize('remind', Invoice)
    for (const invoice of invoices) {
      await reminders.send(invoice)            // queue the email
      invoice.fill({ reminded_at: request.now })
      await invoice.save()                     // same observer, same batch rules
    }
  }
}
POST /v1/invoices/actions?action=send-reminders
{ "repositories": ["inv_123", "inv_124", "…"] }

The kit loads the selection in chunks and binds the ids as a single JSON parameter, so selecting 500 invoices doesn’t hit the database’s limit on bound parameters. handle receives real Invoice models, not rows, so everything the model knows still applies.

4. A payment webhook, with no HTTP request at all. The payment provider confirms a payment, and a queue consumer marks the invoice paid:

export default {
  async queue(batch: MessageBatch<PaymentConfirmed>) {
    for (const message of batch.messages) {
      const context = ModelContext.for(paymentsService(message.body.accountId))
      const invoice = await Invoice.in(context.scope).findOrFail(message.body.invoiceId)
      invoice.fill({ status: 'paid' })
      await invoice.save()
      message.ack()
    }
  },
}

There’s no router and no route gate here. The consumer runs as a named service actor, so the action log still says who made the change. The model, the soft-delete rules, the observer that stamps paid_at and the atomic write are exactly the ones flow 2 used. Paid is paid, whether a person clicked a button or a payment provider called back.

This is the part a bare handler can’t give you. With hand-written Hono routes, flows 2 and 4 would be two separate implementations of “mark paid”, and sooner or later one of them would forget to set paid_at.

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. Getting there took a real refactor. An early version had a separate record type, so record instanceof Invoice was false, which surprised everyone who had used Eloquent.

The repository: Restify’s job, with no controllers

Compare this with the PHP repository above:

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. The response is built by allowlist: a column not in fields is never sent, which ends the SELECT * leaks for good. Search and sort are declared, and identifiers are checked against the declared columns before they reach SQL, so a query parameter can never be spliced in. A create is Restify’s own store(), fillFields then save, made public as Repository.fill(request, body) followed by model.save(). The same pair works in a queue consumer or an import action, not just behind POST /.

Policies, observers and an Auth facade

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 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 keep Laravel’s names: an app’s User extends the kit’s base User, Auth.user() reads the current principal, and an action calls Gate.authorize('import', Contact). The services that use the kit even mirror a Laravel project’s layout: Models/, Policies/, Observers/, Rules/, Actions/ and an EventServiceProvider. Someone who knows Laravel can open one and find their way around in minutes.

Where Workers said no

Borrowing Restify’s shape doesn’t mean pretending to be PHP. A few things forced a different design, and each one is written down rather than hidden.

  • There’s no single database. In Laravel everything talks to one connection. On Workers, data can live in D1 or in a tenant’s Durable Object. So validation, auth and the policy’s route check stay in the Worker, and the write runs where the data is: one call per operation, with plain data crossing back. A database port with two drivers keeps the same model code working on both.
  • Tenancy is a parameter, not a guess. Laravel apps usually scope tenants through global scopes and middleware. An early version of the kit tried the same with a host-wide “tenancy resolver”, and once it bound the wrong tenant id. Now tenantId is the first argument of every by-key method on a tenant repository, so find(id) simply doesn’t compile.
  • No ORM underneath. Eloquent sits on a full query builder. The kit uses a ~90-line SQL builder with one rule: every interpolated value becomes a bound ?, and only declared, quoted identifiers are ever spliced.
  • Bound parameters are capped. D1 and Durable Object SQLite allow roughly a hundred per statement, so an action over a large selection binds its ids as one JSON parameter (json_each).
  • Actions are compiled once per isolate. Restify builds actions per request. On Workers the kit builds 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 is why the kit could change this much in three months without breaking the services built on it.

What it cost, and what we got

A shared kit means a breaking release touches every service. We keep that manageable with a small public surface, loud breaking changes, and an upgrade guide per release.

What we got: Hono stays fast and minimal at the edge, and on top of it every service pages, filters, authorizes, errors and documents itself the same way. A new resource is a model, a repository and a policy. And the migration from Laravel didn’t mean the team had to unlearn anything: the shape they used every day came along with them.

If you’re moving a Laravel app to Workers, I’d steal Restify’s design before inventing your own. Hono gives you the handler. You still need the framework.

Found this useful? Pass it on.

share: LinkedIn X