Laravel vs. Toast
A side-by-side comparison of how you'd build a feature in Laravel versus Toast, covering data access, routing, controllers, validation, and testing.
This walks through how you'd build and work with a feature — tags — in Laravel versus Toast. Every Toast example is pulled from real code in the codebase. If you've built things in Laravel, the left side should feel familiar; the right side shows how the same job gets done here.
For background on the underlying design patterns, see Martin Fowler's Active Record, Data Mapper, and Repository definitions from Patterns of Enterprise Application Architecture (2003). Laravel uses the Active Record pattern (via Eloquent). Toast uses the Data Mapper + Repository pattern (via Drizzle ORM).
Contents
- Defining the data
- Querying data
- Creating a record
- Updating a record
- Deleting a record
- Registering routes
- Defining individual routes
- Controllers
- Validation
- Testing
- The request lifecycle, compared
- How it gets assembled
- Where does the logic live?
1. Defining the data
In Laravel, the schema lives in a migration file. The model discovers columns at runtime:
// database/migrations/create_tags_table.php
Schema::create('tags', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('site_id')->constrained()->cascadeOnDelete();
$table->string('name', 50);
$table->string('slug', 100);
$table->timestamps();
$table->index('site_id');
$table->unique(['site_id', 'slug']);
});// app/Models/Tag.php
class Tag extends Model
{
protected $fillable = ['name', 'slug', 'site_id'];
}In Toast, the schema is a Drizzle table definition. TypeScript infers the column types at compile time — there is no model class:
// shared/db/src/schema.ts
export const tags = pgTable(
'tags',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id')
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 50 }).notNull(),
slug: varchar('slug', { length: 100 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
},
(table) => [
index('idx_tags_site_id').on(table.siteId),
unique('uq_tags_site_slug').on(table.siteId, table.slug),
]
);The schema definition is the source of truth. typeof tags.$inferSelect gives you the row type. No $fillable, no $casts, no runtime introspection.
2. Querying data
In Laravel, the model is the query builder:
// Find one
$tag = Tag::where('site_id', $siteId)->where('id', $id)->first();
// List with search
$tags = Tag::where('site_id', $siteId)
->where(function ($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%")
->orWhere('slug', 'ilike', "%{$search}%");
})
->orderBy('name')
->paginate(20);In Toast, queries live in a dedicated repository. The repository is a factory function that takes a database connection:
// apps/api/src/repositories/tags.repository.ts
export function createTagsRepository(db: PostgresJsDatabase) {
return {
async findById(id: string, siteId: string) {
const result = await db
.select()
.from(tags)
.where(and(eq(tags.id, id), eq(tags.siteId, siteId)));
return result[0] ?? null;
},
async findBySiteId(siteId: string, options: { limit: number; offset: number; query?: string }) {
return await db
.select()
.from(tags)
.where(buildTagSearchCondition(siteId, options.query))
.orderBy(asc(tags.name))
.limit(options.limit)
.offset(options.offset);
},
};
}The query builder mirrors SQL structure (select → from → where → orderBy). The result is a plain object — { id, name, slug, siteId } — not a model instance.
3. Creating a record
In Laravel, you either mass-assign and save, or use create:
$tag = Tag::create([
'site_id' => $siteId,
'name' => $name,
'slug' => Str::slug($name),
]);In Toast, the repository handles the insert. The service handles the business logic around it:
// Repository — just the insert
async create(data: CreateTagData) {
const results = await db.insert(tags).values(data).returning();
return results[0] ?? null;
},
// Service — business rules, then delegates to repository
async create(input: { name: string }, siteId: string) {
const name = normalizeTagName(input.name);
const slug = createTagSlug(name);
// Idempotent: return existing tag if slug already exists
const existing = await repo.findBySlug(slug, siteId);
if (existing) return toResponse(existing);
const created = await repo.create({ siteId, name, slug });
return toResponse(created);
},The split is deliberate. The repository knows how to insert a row. The service knows whether to insert — checking for duplicates, normalizing names, generating slugs. In Laravel these would typically all live in the model or a controller.
4. Updating a record
In Laravel, you find the model instance and mutate it:
$tag = Tag::where('site_id', $siteId)->findOrFail($id);
$tag->name = $name;
$tag->slug = Str::slug($name);
$tag->save();In Toast, there's no mutable object. The repository takes the ID and the new data:
// Repository
async update(id: string, siteId: string, data: Partial<...>) {
const results = await db
.update(tags)
.set(data)
.where(and(eq(tags.id, id), eq(tags.siteId, siteId)))
.returning();
return results[0] ?? null;
},
// Service
async update(id: string, siteId: string, input: { name: string }) {
const name = normalizeTagName(input.name);
const slug = createTagSlug(name);
return await repo.update(id, siteId, { name, slug });
},No dirty tracking, no implicit saves. Every write is an explicit db.update() call.
5. Deleting a record
Laravel:
$tag = Tag::where('site_id', $siteId)->findOrFail($id);
$tag->delete();Toast:
async deleteById(id: string, siteId: string) {
const results = await db
.delete(tags)
.where(and(eq(tags.id, id), eq(tags.siteId, siteId)))
.returning();
return results[0] ?? null;
},6. Registering routes
In Laravel, routes/api.php is the central place where you map URLs to controllers:
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('tags', TagController::class);
});Laravel auto-discovers this file via RouteServiceProvider, which prefixes everything with /api and applies the api middleware group. One line gives you GET /api/tags, POST /api/tags, GET /api/tags/{id}, PUT /api/tags/{id}, and DELETE /api/tags/{id}.
In Toast, app.ts does the same job — it mounts feature route groups at their prefixes:
// apps/api/src/app.ts
app.route('/api/tags', deps.routes.tags);
app.route('/api/content', deps.routes.content);
app.route('/api/users', deps.routes.users);
// ... one line per feature, same as LaravelThis is the equivalent of Laravel's route file. Each deps.routes.tags is a self-contained Hono app with its own routes already defined inside. app.ts just decides where they live in the URL tree.
Global middleware is also applied here, in the same way Laravel's Kernel.php defines the middleware stack:
// apps/api/src/app.ts — global middleware, like Laravel's Kernel.php
app.use('*', requestId()); // assigns a request ID
app.use('*', requestLogger()); // logs method, path, status, duration
app.use('*', cors({ origin })); // CORS handling
app.use('/api/*', session()); // extracts user/session from cookie7. Defining individual routes
In Laravel, if you're not using apiResource, you define routes with Route::get, Route::post, etc:
Route::get('/tags', [TagController::class, 'index']);
Route::post('/tags', [TagController::class, 'store']);
Route::get('/tags/{id}', [TagController::class, 'show']);
Route::put('/tags/{id}', [TagController::class, 'update']);
Route::delete('/tags/{id}', [TagController::class, 'destroy']);In Toast, each route is defined with createRoute() from @hono/zod-openapi. This combines the route path, HTTP method, request validation, and API documentation into a single declaration:
// apps/api/src/routes/tags/routes.ts
const listTagsRoute = createRoute({
method: 'get',
path: '/',
tags: ['Tags'],
summary: 'List tags items for site',
request: { query: TagsListQuerySchema },
responses: {
200: { content: { 'application/json': { schema: TagsListSchema } } },
401: {
content: { 'application/json': { schema: UnauthorizedErrorSchema } },
},
},
});
const createTagsRoute = createRoute({
method: 'post',
path: '/',
tags: ['Tags'],
summary: 'Create tags',
request: {
body: { content: { 'application/json': { schema: CreateTagsSchema } } },
},
responses: {
201: { content: { 'application/json': { schema: TagsSchema } } },
// ... error responses
},
});These route definitions do double duty — they define the endpoint and generate OpenAPI documentation automatically. In Laravel you'd need a separate tool like Scribe or L5-Swagger to get API docs from your routes.
The routes are then wired to the controller in createTagsRoutes():
// apps/api/src/routes/tags/routes.ts
export function createTagsRoutes(deps: TagsRoutesDeps) {
const routes = new OpenAPIHono<ApiEnv>();
const controller = createTagsController({ tagsService: deps.tagsService });
routes.use('*', requirePermission({ content: ['create'] }));
routes.openapi(listTagsRoute, (c) => {
const query = c.req.valid('query'); // already validated by Zod
return controller.listTags(c, query);
});
routes.openapi(createTagsRoute, (c) => {
const body = c.req.valid('json'); // already validated by Zod
return controller.createTags(c, body);
});
// ... get, update, delete follow the same pattern
return routes;
}Notice that by the time the controller is called, the request has already been validated. c.req.valid('query') and c.req.valid('json') return typed, validated data — the same job that Laravel's Form Requests handle, but driven by the route definition rather than a separate class.
8. Controllers
In Laravel, a controller handles the HTTP request and typically talks to the model directly:
class TagController extends Controller
{
public function index(Request $request)
{
$tags = Tag::where('site_id', $request->site_id)
->orderBy('name')
->paginate($request->input('limit', 20));
return TagResource::collection($tags);
}
public function store(StoreTagRequest $request)
{
$tag = Tag::create([
'site_id' => $request->site_id,
'name' => $request->name,
'slug' => Str::slug($request->name),
]);
return new TagResource($tag);
}
}In Toast, the controller is deliberately thin — it extracts the auth context, delegates to the service, and formats the HTTP response. That's it:
// apps/api/src/routes/tags/tags.controller.ts
export function createTagsController(deps: TagsControllerDeps) {
const { tagsService: service } = deps;
return {
async listTags(c: Context, query: TagsListQuery) {
const { siteId } = getAuthContext(c);
const result = await service.list(siteId, {
page: query.page,
limit: query.limit,
});
return c.json(result, 200);
},
async createTags(c: Context, body: CreateTags) {
const { user, siteId } = getAuthContext(c);
const result = await service.create(body, siteId, user.id);
return c.json(result, 201);
},
async getTags(c: Context, id: string) {
const { siteId } = getAuthContext(c);
const item = await service.getById(id, siteId);
if (!item) return c.json({ error: 'Tags not found' }, 404);
return c.json(item, 200);
},
};
}The controller never constructs a query, never applies business rules, and never touches the database. Business logic like slug generation and duplicate checking lives in the service. Data access lives in the repository. The controller is just the HTTP adapter between the two.
9. Validation
In Laravel, you write a form request:
class StoreTagRequest extends FormRequest
{
public function rules()
{
return [
'name' => 'required|string|max:50',
];
}
}In Toast, validation schemas are Zod objects defined in a shared contracts package — used by both the server and the client:
// shared/contracts/src/tag.ts
export const CreateTagSchema = z
.object({
name: z.string().min(1).max(50),
})
.openapi('CreateTag');These schemas serve three purposes at once: request validation (via the route definition), TypeScript type inference, and OpenAPI documentation. In Laravel you'd need Form Requests for validation, API Resources for response shaping, and a separate tool for docs. In Toast, the Zod schema handles all three.
Hono validates the request body against this schema automatically before the handler runs. c.req.valid('json') returns the validated, typed data — if validation fails, the response is a 422 with structured errors before the controller is ever called.
10. Testing
In Laravel, testing a feature typically requires the database:
public function test_can_create_tag()
{
$site = Site::factory()->create();
$response = $this->postJson("/api/tags", [
'name' => 'JavaScript',
'site_id' => $site->id,
]);
$response->assertStatus(201);
$this->assertDatabaseHas('tags', ['slug' => 'javascript']);
}In Toast, because the service takes its dependencies as arguments, you can test business logic without a database by mocking the repository:
const mockRepo = {
findBySlug: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: '1', name: 'JavaScript', slug: 'javascript' }),
};
const service = createTagsService({ tagsRepository: mockRepo });
const result = await service.create({ name: 'JavaScript' }, siteId, userId);
expect(mockRepo.create).toHaveBeenCalledWith({
siteId,
name: 'JavaScript',
slug: 'javascript',
});The service doesn't know or care whether the repository is real or mocked. That's the testing payoff of the separation.
11. The request lifecycle, compared
Laravel:
HTTP Request
→ Kernel.php (global middleware)
→ routes/api.php (match URL to controller method)
→ FormRequest (validate)
→ Controller (auth, business logic, query via Model, format response)
→ API Resource (shape response)
→ ResponseToast:
HTTP Request
→ app.ts (global middleware: request ID, logging, CORS, session)
→ app.route('/api/tags', ...) (match URL prefix to feature routes)
→ routes.ts (match specific path, validate request via Zod schema)
→ requirePermission() (check auth + role)
→ controller (extract auth context, delegate to service, format response)
→ service (business logic: normalize, check duplicates, orchestrate)
→ repository (data access: Drizzle query, scoped by siteId)
→ ResponseThe difference is that Laravel concentrates most of the work in the controller and model. Toast fans it out across more layers, each with a narrower responsibility.
12. How it gets assembled
You've now seen each layer in isolation. The remaining question is: how do they get wired together?
In Laravel, the service container handles dependency injection automatically. You type-hint a class in a constructor and Laravel resolves it:
class TagController extends Controller
{
public function __construct(private TagService $tagService) {}
}You might register bindings in a service provider, but for most classes Laravel figures it out through reflection. You don't usually think about wiring — it just works.
Toast has no auto-resolving container. Instead, container.ts is a composition root — a single file that manually wires everything together when the server starts:
// apps/api/src/container.ts (simplified)
export function buildStacks({ db, eventBus, logger, config }) {
// Build repositories (data access)
const tagsRepository = createTagsRepository(db);
// Build services (business logic), injecting their dependencies
const tagsService = createTagsService({ tagsRepository });
return { tagsService /* ...other services */ };
}Then in the entry point, those services are passed to route factories:
// apps/api/src/index.ts
const stacks = buildStacks({ db, eventBus, logger, config });
const routes = {
tags: createTagsRoutes({ tagsService: stacks.tagsService }),
// ...
};
const app = createApp({ config, logger, auth, routes });The full startup sequence is:
index.ts
→ buildConfig() validate env vars
→ buildInfrastructure(config) create logger, db pool, event bus
→ buildStacks({ db, eventBus, ... }) wire repositories → services
→ createTagsRoutes({ tagsService }) wire services → routes
→ createApp({ routes, ... }) mount routes, apply middleware
→ serve({ fetch: app.fetch, port }) start listeningThis is more explicit than Laravel's container — you can read container.ts and see every dependency relationship in one place. The tradeoff is that you wire things manually instead of relying on auto-resolution. When you add a new feature, you add its wiring here too.
13. Where does the logic live?
This is the fundamental difference. In Laravel, the model is the center of gravity — most things either happen on the model or are one step away from it.
In Toast, responsibilities are distributed across layers:
| Responsibility | Laravel | Toast |
|---|---|---|
| Table structure | Migration + Model | Drizzle schema (shared/db/src/schema.ts) |
| API shape / validation | Form Request + API Resource | Zod schemas (shared/contracts/src/tag.ts) |
| Data access | Model (Eloquent) | Repository (repositories/tags.repository.ts) |
| Business logic | Model or Controller | Service (services/tags.service.ts) |
| HTTP handling | Controller | Controller (routes/tags/tags.controller.ts) |
| Route registration | routes/api.php | app.ts (mounting) + routes.ts (definitions) |
| Middleware stack | Kernel.php | app.ts (global middleware) |
| Dependency wiring | Service container (automatic) | container.ts (manual composition root) |
| API documentation | Scribe / Swagger (separate) | Generated from route definitions (built-in) |
More files, yes. But each file does one thing, and when something goes wrong, the layer tells you exactly where to look.