Skip to main content
ASoc
Comparison

Laravel vs. Next.js: 897 Lines of Server Actions Where Eloquent Would Live

No ORM, no Artisan, no PHP anywhere — 271 lines of SQL and 897 lines of Server Actions doing what Laravel's migrations and controllers do instead.

The ASoc Team8 min read

Laravel is a PHP framework that bundles routing, an ORM, an admin-friendly console, and authentication scaffolding into one full-stack package. Next.js is a React framework for rendering and routing; it has no ORM, no CLI code generator, and no built-in auth system, so "the backend" is whatever you build with Server Actions, Route Handlers and a database client. This repository is the second kind: zero PHP anywhere, 271 lines of SQL doing what Eloquent migrations do, and 897 lines of TypeScript doing what Laravel controllers do.

What "backend" means on each side

Laravel ships Eloquent (an ORM with model classes and relationships), Artisan (php artisan make:migration, make:controller, make:model — code generators for all of it), a request/controller/route pipeline, and Sanctum or Breeze for authentication, all in the framework's own conventions. Next.js ships none of that. It gives you file-based routing, Server Components, and two ways to run server code — Server Actions and Route Handlers — and leaves the database, the ORM decision, and the auth system entirely up to you. Django's version of the same gap is even starker, because the admin panel it hands you for free has no equivalent here at all.

$ find . -maxdepth 3 -iname "*.php" | grep -v node_modules
$

No output. This isn't a migration story — this storefront was Next.js from its first commit — but it's the honest starting point for the comparison: everything below is what a team builds when the framework doesn't build it for them.

Migrations: the same word, two different tools

Laravel migrations are PHP classes with up()/down() methods, run through php artisan migrate, and are usually paired with Eloquent models that map each table to a class. This codebase's migrations are plain SQL, run through the Supabase CLI, with no ORM layer sitting on top of them at all:

$ ls supabase/migrations/
0001_commerce_init.sql
0002_display_name_allowlist.sql
0003_commerce_rpcs.sql
0004_lock_down_rate_limit_fn.sql
0005_retain_anonymized_download_audit.sql
0006_pricing_v2_slot_kinds.sql
0007_atomic_download_rate_limit.sql
0008_refund_requests.sql
$ wc -l supabase/migrations/*.sql | tail -1
   271 total

Eight files, 271 lines, hand-written Postgres — create table, alter table ... enable row level security, create policy. A Laravel migration for the same schema would generate broadly similar DDL underneath, but through Schema::create()'s fluent builder rather than raw SQL, and it would expect an Eloquent model on the other side translating rows to objects. Nothing here does that translation. Every query in this codebase — Server Action or Route Handler — calls the Supabase client directly and gets back plain rows typed by hand, not model instances with relationship methods attached.

Where Eloquent's job goes instead

A Laravel app puts write logic in a controller method, usually backed by a model's save() or update(). This app puts it in a "use server" function:

$ grep -rl '"use server"' src/lib/actions/*.ts
src/lib/actions/account.ts
src/lib/actions/auth.ts
src/lib/actions/checkout.ts
src/lib/actions/contact.ts
src/lib/actions/entitlementsView.ts
src/lib/actions/newsletter.ts
src/lib/actions/redemption.ts
src/lib/actions/refund.ts
$ grep -c '^export async function' src/lib/actions/*.ts | awk -F: '{sum+=$2} END {print sum}'
18
$ wc -l src/lib/actions/*.ts | tail -1
897 total

Eight files, 18 exported functions, 897 lines — this codebase's entire mutation surface, called directly from Server Components with no route or controller class in between. redeemSlot() in redemption.ts does what a Laravel RedemptionController@store method plus an Eloquent transaction would: validates the input, checks the caller owns the entitlement, writes the update, all in one function with no framework-mandated layering around it. There's no RedemptionRequest form-request class, no RedemptionController, no Redemption model — a Laravel team would write three files where this one writes one function.

Two Route Handlers exist for the cases a Server Action can't cover — a webhook needs a stable URL a third party can POST to, and a signed-download endpoint needs to return a redirect rather than serialized data:

$ find src/app/api -name "route.ts"
src/app/api/webhooks/lemonsqueezy/route.ts
src/app/api/download/route.ts

Two files. A Laravel equivalent would route both through routes/api.php to controller methods; the shape is comparable, the ceremony around it isn't.

What Laravel gives you that this stack doesn't

LaravelThis codebase
ORM / model layerEloquent — model classes, relationships, query builderNone — direct Supabase client calls, hand-typed rows
Code generationartisan make:* scaffolds migrations, controllers, models, requestsNone — every file is written from scratch
Auth scaffoldingBreeze/Sanctum/Fortify — ships as a packageHand-built (src/lib/actions/auth.ts), against Supabase Auth directly
Admin/back-office UINova (paid) or community packages generate CRUD screens from modelsHand-built dashboard (src/app/dashboard/, 3 files, 556 lines)
AuthorizationPolicy classes + gates, framework-integratedPostgres row-level security (see this schema's RLS posture)
Where "write" logic livesControllers, form requests, model eventsServer Actions, one function per operation
Database portabilityLaravel's query builder abstracts several SQL dialectsSupabase-specific: RPCs, RLS policies and Storage calls, all Postgres

The honest reading of this table isn't "Next.js is missing features" — it's that Laravel bundles opinions Next.js deliberately leaves unbundled. A team that wants Eloquent's conventions, Artisan's generators, and a back-office UI out of the box gets real, non-trivial value from Laravel that this stack doesn't offer. A team assembling a Postgres-backed storefront with row-level security as the authorization layer — this codebase's actual choice — ends up writing the 897 lines above instead of learning Eloquent's, and keeps direct control over exactly what every write path does, including the one race condition (the F-1 download-limit fix) that got fixed at the SQL level rather than inside a framework's model-event system.

Mistakes going either direction

SymptomCauseFix
Porting a Laravel model's $fillable/$guarded habit to a Server ActionMass-assignment protection is an Eloquent concept; Server Actions have no equivalent unless you write itValidate and allowlist fields explicitly inside the action, the way src/lib/actions/account.ts does per field
Expecting a Next.js Route Handler to auto-wrap responses like Laravel's Response macrosNext.js Route Handlers return a plain Response/NextResponse — no framework response transformationBuild the response shape by hand, or write a small shared helper if the pattern repeats
Assuming RLS is "Laravel policies, but in the database"RLS evaluates per-row, per-SQL-statement, independent of anything in the app layer; a Laravel policy only runs when a controller calls authorize()Treat RLS as a backstop that runs even if the app layer has a bug, not a drop-in replacement for explicit checks in Server Actions
Looking for an Eloquent-style Model::find() and not finding oneThis codebase never introduced an ORMQuery the Supabase client directly and type the row shape by hand, as every action file here does
Expecting migrations to know about model relationshipsLaravel migrations are commonly authored alongside Eloquent models that encode hasMany/belongsToThis schema's relationships are just foreign keys (references auth.users(id)); nothing generates or enforces them beyond the SQL constraint itself

Frequently asked questions

Could this codebase have been built in Laravel instead? Structurally, yes — Postgres, row-level security and webhook-driven order creation aren't Next.js-specific ideas. The costs would move, not disappear: Eloquent and Artisan would remove some of the 897 lines of hand-written Server Actions, but the framework doesn't know about Supabase's storage buckets, signed URLs or SECURITY DEFINER RPCs, so that half would still be custom code either way.

Does Next.js have anything like Artisan's code generation? Not built-in. The closest analogues are third-party CLI tools (create-next-app for scaffolding a whole project, various ORM CLIs like Prisma's or Drizzle's for schema-first generation) — none of them ship with the framework the way Artisan ships with Laravel, and this codebase uses none of them, writing both the SQL and the TypeScript by hand.

Is Postgres row-level security a fair swap for Laravel's policy classes? They solve overlapping but not identical problems. A Laravel policy is application code that runs when a controller calls it — skip the call, skip the check. An RLS policy runs inside Postgres itself for every query that touches the table, application code or not. This schema leans on that distinction directly: zero INSERT policies exist on any table, so a write attempted outside the server's SECURITY DEFINER functions fails at the database, not at a layer a bug could bypass.

Which one is faster to build a CRUD admin panel in? Laravel, decisively, if a generated back office is acceptable — Nova or a community package can produce CRUD screens from Eloquent models with little custom code. This codebase's dashboard is hand-built for a narrower job (viewing entitlements and redeeming slots, not general CRUD), and it shows: three files, 556 lines, purpose-built rather than generated.

Does this codebase's lack of an ORM show up anywhere as duplicated code? Yes — an interface named SlotRow, describing the same entitlement_slots row shape, is declared independently in five different files (src/app/api/download/route.ts, src/app/dashboard/page.tsx, src/lib/actions/entitlementsView.ts, src/lib/actions/redemption.ts, src/lib/actions/refund.ts). An Eloquent model would define that shape once; this codebase re-derives it at every call site instead, which is the direct cost of skipping the model layer — more places to keep in sync by hand if the schema changes.

Templates in this post

ASoc Brief (a resume & portfolio site), ASoc Byte (an IT company website) and ASoc Canvas (a no-code page-builder landing page) are all Next.js editions built the way this post describes — no ORM, no PHP, Server Actions doing the write work.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the database half of this stack in more depth, see Supabase vs Firebase for a Template-Based SaaS; for the framework-choice question from the frontend side, Next.js vs. Angular.

Keep reading

Comparison10 min read

MDX vs a Headless CMS: Choosing by Who Writes the Posts

An editorial-workflow decision wearing an architecture decision's clothes. What MDX buys you in CI, the Turbopack plugin trap, and the four cases where a CMS simply wins.

Read more
Comparison11 min read

Monorepo vs Multi-Repo: 110 Products Across 113 Repositories

Choose by the boundary the customer receives, not by code sharing. The cost is not merging — it is the generated index, and ours already had four bad rows.

Read more