From Laravel to Cloudflare Workers: rebuilding Laravel Restify on Hono
19 October 2026 · 16 min read
- Laravel Restify
- Cloudflare Workers
- Hono
- TypeScript
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:
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:
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 Restify | Our 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, $match | searchable, orderBy, filters, declared and allowlisted |
Eloquent Model | Model with columns, relations, timestamps, softDeletes |
Policy and the Gate | ResourcePolicy, Gate.authorize(), Auth.user() |
| Model observers | ModelObserver running inside the write’s atomic batch |
fillFields() then save() | Repository.fill() then model.save() |
| Restify actions | Standalone, show and index actions |
| DB connection | A 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:
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
tenantIdis the first argument of every by-key method on a tenant repository, sofind(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.