Ruby on Rails vs Laravel
Both frameworks do the same job, both ship a router, an ORM, migrations, a queue, a mailer and a console, and almost every page comparing them was written by somebody with one of the two installed. So this page has both. Rails 8.1.3.1 and Laravel 13.33.0 sit in two directories on one laptop, pointed at the same PostgreSQL, holding the same two tables and the same 500 rows, and every number and error string below came out of that pair this afternoon.
Conditions, because a number without them is not checkable. Apple M2 Max, 12 cores, macOS
arm64-darwin25. Ruby 4.0.5 (2026-05-20 revision 64336ffd0e) with PRISM, built by RVM with no YJIT.
Rails 8.1.3.1 on Puma 8.0.2, RAILS_ENV=production. PHP 8.5.11 NTS as built by Homebrew, with
opcache.jit => disable and opcache.enable_cli => Off in the default configuration, Composer
2.10.3, PHPUnit 12.5.36, Laravel 13.33.0 with APP_ENV=production and APP_DEBUG=false.
PostgreSQL 17.7 (Homebrew) on port 15432, loopback only. Both applications serve /posts: 50 posts
with their author's name, eager loaded, as JSON.
The two models are the same shape on both sides. Here is the Laravel one:
class Post extends Model
{
protected $fillable = ['author_id', 'title', 'body', 'published_at'];
public function author(): BelongsTo
{
return $this->belongsTo(Author::class);
}
}
class Post < ApplicationRecord
belongs_to :author
validates :title, presence: true, length: { maximum: 200 }
validates :body, presence: true
end
Those two listings are not the same file in two languages, and the differences between them are
three of the sections below: the $fillable line, the two validates lines that have no Laravel
counterpart, and the fact that the Ruby class never names a column.
A PHP process keeps nothing, and that is where the rest of this page comes from
Rails boots once and then answers requests out of a process that is still warm. PHP boots the framework, answers one request, and discards everything it built. Both applications got the same route, a static counter incremented on every hit, and three curl calls each:
$ for i in 1 2 3; do curl -s localhost:3987/counter; echo; done
{"in_process_count":1,"pid":1858,"uptime_s":8.7,"files_loaded":2308}
{"in_process_count":2,"pid":1858,"uptime_s":8.71,"files_loaded":2308}
{"in_process_count":3,"pid":1858,"uptime_s":8.72,"files_loaded":2308}
$ for i in 1 2 3; do curl -s localhost:8987/counter; echo; done
{"in_process_count":1,"pid":1930,"boot_ms":128.11,"files_loaded":422}
{"in_process_count":1,"pid":1930,"boot_ms":8.31,"files_loaded":422}
{"in_process_count":1,"pid":1930,"boot_ms":8.15,"files_loaded":422}
Same pid on the Laravel side, and the counter still reads 1 on the third request. The PHP built-in
server reuses the operating system process and PHP resets the entire execution context between
requests anyway, so a static property is not shared state, it is a local variable with a long name.
boot_ms is microtime(true) - LARAVEL_START, measured inside the route closure, where
LARAVEL_START is defined on line 6 of public/index.php. Over 20 requests with OPcache on and
php artisan optimize applied, that number had a median of 7.505 ms, a minimum of 6.56 and a
maximum of 10.4. The whole request, measured by ApacheBench at one connection, took 8.796 ms. So
roughly 85 percent of a Laravel request on this machine is the framework standing itself up, and it
does it again on the next request.
What Rails spends instead is memory and boot latency you pay once. 2308 files were loaded in that process, against 422 in the PHP one, and a Puma worker under load held 98.3 MB resident against 25.6 MB for a PHP worker. Those are the two halves of the same trade and neither is free.
The consequence developers actually hit is not the millisecond. It is that a whole category of Rails
advice does not port. Memoising in a class variable, ActiveSupport::CurrentAttributes, a connection
pool, an in-process cache, a background thread: all of those assume a process that outlives the
request, and in PHP the equivalent has to be Redis, APCu or the database. Laravel Octane exists to
buy exactly that back and is not in the skeleton's composer.json; I did not run it and nothing on
this page is a claim about it.
The same difference bit me in the test suite, which is where it is least expected. A class declared
in routes/web.php is fine in production, because the route file is read once per boot and there is
one boot per request. PHPUnit boots the application once per test in one process, and
RouteFileRegistrar::register uses a bare require on line 34, not require_once:
PHP Fatal error: Cannot redeclare class RequestCounter (previously declared in
/.../lv/routes/web.php:15) in /.../lv/routes/web.php on line 15
Fatal error: Premature end of PHP process when running
Tests\Feature\EloquentBehaviourTest::test_reading_an_unknown_attribute_returns_null.
Moving the class into app/Support/RequestCounter.php, where the Composer autoloader handles it,
fixed it. The bug only exists because the suite does something production never does, which is boot
the framework twice in one process.
Validation lives in the controller in Laravel and in the model in Rails
Eloquent has no validation. None: there is no validates on the model, and Post::create() sends
whatever it is given straight to an INSERT. Laravel validates in the request instead, either with
$request->validate() in the controller or with a FormRequest class, and the route on the Laravel
side of this comparison is the idiomatic version:
Route::post('/posts', function (Request $request) {
$data = $request->validate([
'author_id' => ['required', 'integer', 'exists:authors,id'],
'title' => ['required', 'string', 'max:200'],
'body' => ['required', 'string'],
]);
return Post::create($data);
});
That works, and it returns a 422 with a structured error body for free. The test asserts the exact shape:
$response = $this->postJson('/posts', ['author_id' => $author->id, 'title' => '', 'body' => '']);
$response->assertStatus(422);
$this->assertSame(
['title' => ['The title field is required.'], 'body' => ['The body field is required.']],
$response->json('errors')
);
$this->assertSame(0, DB::table('posts')->count());
The gap is every write that is not that controller. A seeder, an artisan command, a queued job, a console session, a second endpoint somebody added in a hurry: none of them go through the rule, and Eloquent will not stop them.
Post::create(['author_id' => $author->id, 'title' => '', 'body' => '']);
$this->assertSame(1, DB::table('posts')->count());
$this->assertSame('', DB::table('posts')->value('title'));
Both of those pass. On the Rails side the same two paths hit the same rule, because the rule is on the model:
test "the same write outside the controller is refused by the same rule" do
assert_raises(ActiveRecord::RecordInvalid) do
Post.create!(author_id: @author.id, title: "", body: "")
end
assert_equal 0, Post.count
end
The cost of the Rails arrangement is the one its critics name and it is real: a validation that
belongs to one form ends up on the model, where it applies to the admin import and the backfill
script too, and the escape hatches are validate: false, on: :create and a context argument that
nobody remembers exists. Laravel's arrangement is honest about forms being different from records.
It just leaves the database with no opinion about what a valid row is, and a table that anything can
write to eventually contains everything.
If you take one thing from this section, put a CHECK constraint on the column in both frameworks.
Neither ORM is the thing standing between your table and a blank title; PostgreSQL is.
A note on how that Rails test got written, because the detour is worth more than the test. rails new generates a
Gemfile with no json entry, activesupport 8.1.3.1 declares its dependency as json >= 0, and
Bundler today resolves that to json 3.0.2, whose JSON.parse signature is
parse(source, on_load: nil, object_class: nil, array_class: nil, **options). ActiveSupport::JSON.decode
calls ::JSON.parse(json, options) with a positional hash at active_support/json/decoding.rb:25,
so every JSON request body in the application answers 400:
ActionDispatch::Http::Parameters::ParseError (Error occurred while parsing request parameters)
Caused by: ArgumentError (wrong number of arguments (given 2, expected 1))
The fix is gem "json", "~> 2.21", which is what this site's own Gemfile.lock already pins.
Eloquent does not know what is in the table
Active Record asks the database for the columns at boot and types every attribute from what it
finds. Eloquent asks nothing: a model is a bag of whatever the SELECT returned, with created_at
and updated_at special-cased. HasAttributes::getDates(), at line 1667 of
Concerns/HasAttributes.php, is the whole list:
public function getDates()
{
return $this->usesTimestamps() ? [
$this->getCreatedAtColumn(),
$this->getUpdatedAtColumn(),
] : [];
}
So a timestamp column you declared yourself comes back as a string:
$ php artisan tinker --execute='$p = App\Models\Post::first(); var_dump($p->nope, $p->title, $p->published_at);'
NULL
string(6) "Post 0"
string(19) "2026-09-27 15:00:36"
Three things in that output. published_at is a string, not a date, until
protected $casts = ['published_at' => 'datetime'] is written on the model, and the test confirms it
becomes an Illuminate\Support\Carbon the moment it is. $post->getCasts() on the undeclared model
returns exactly ['id' => 'int'], which comes from getCasts() merging the primary key in and
nothing else. And $p->nope is NULL, silently, which is the same failure mode as a typo in a
column name.
The same three on the Rails side:
$ bin/rails runner 'p = Post.first
begin; p.nope; rescue NoMethodError => e; puts "NoMethodError: #{e.message.lines.first.strip}"; end
p p.title; p p.published_at
begin; Post.new(nope: 1); rescue ActiveModel::UnknownAttributeError => e; puts "#{e.class}: #{e.message}"; end'
NoMethodError: undefined method 'nope' for an instance of Post
"Post 0"
2026-09-27 14:57:50.627999000 UTC +00:00
ActiveModel::UnknownAttributeError: unknown attribute 'nope' for Post.
published_at is an ActiveSupport::TimeWithZone with nothing declared anywhere, reading an
unknown attribute raises, and writing one raises before any SQL is sent. Eloquent's equivalent write
gets to the database and comes back as a QueryException containing
column "nope" of relation "posts" does not exist, which is a worse error at a later moment but is
at least an error.
What Eloquent buys with that ignorance is that the model does not need a live database connection to
be useful, and that a SELECT id, title gives you an object with two attributes on it rather than a
half-populated record. Post.select(:id, :title).first.body raises
ActiveModel::MissingAttributeError: missing attribute 'body' for Post instead. Neither is obviously
better. But one of them knows that published_at is a timestamp and one of them does not, and the one
that does not will hand a string to a view that calls ->format() on it.
Two N+1 guards, and they do not guard the same thing
Lazy loading is identical on both sides: ten posts by ten authors, each touching its author, cost 11 queries on Eloquent and 11 on Active Record with the query cache off. Eager loading takes both to 2. Both frameworks also ship a switch that turns the lazy load into an exception, and the switches do not behave the same way.
Laravel's is global, Model::preventLazyLoading(), and the exception message names the model and
the relation:
Attempted to lazy load [author] on model [App\Models\Post] but lazy loading is disabled.
It also does nothing at all when the query returned one row. That is not a bug, it is a line in
Illuminate/Database/Eloquent/Builder.php:
return $instance->newCollection(array_map(function ($item) use ($items, $instance) {
$model = $instance->newFromBuilder($item);
if (count($items) > 1) {
$model->preventsLazyLoading = Model::preventsLazyLoading();
}
return $model;
}, $items));
One row cannot be an N+1, so the flag is never set on it. That surfaced here as a test with one post in it where the
guard did not fire at all; the test now asserts both halves, that one row passes and two rows raise. Rails has no such exemption: Post.strict_loading.first.author raises
ActiveRecord::StrictLoadingViolationError on a single record, which catches the eager-loading bug
earlier and annoys you more.
The other difference in this area is not a guard at all. Ten posts sharing one author cost Eloquent
11 queries and Active Record 2, because Rails has a query cache scoped to the request and Eloquent
has none. grep -rn "queryCache" vendor/laravel/framework/src/Illuminate/Database/ returns nothing.
Wrapping the Rails version in ActiveRecord::Base.uncached puts it back to 11, which is how the test
proves the cache is what did it:
assert ActiveRecord::Base.connection_pool.query_cache_enabled
assert_queries_count(2) { Post.all.each { |p| p.author.name } }
ActiveRecord::Base.uncached do
assert_queries_count(11) { Post.all.each { |p| p.author.name } }
end
That cache is the reason a sloppy Rails page with a repeated parent is merely slow and the same page in Laravel is 11 round trips.
php artisan optimize buys 64 percent and takes env() away
Caching config and routes is the standard Laravel deployment step, and on this endpoint it was worth
more than anything else I changed. With OPcache on but nothing cached, the endpoint did 72.90
requests per second. After php artisan optimize, 119.54, and the loaded file count per request
dropped from 476 to 422.
INFO Caching framework bootstrap, configuration, and metadata.
config .. 5.32ms DONE
events .. 0.59ms DONE
routes .. 7.91ms DONE
views .. 23.67ms DONE
What it costs is that .env stops being read. Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::bootstrap
opens with three lines that return early when $app->configurationIsCached(), so every env() call
outside a config file answers null. Two requests to the same route, once with the config cached and
once without:
$ curl -s localhost:8987/envcheck # config cached
{"env_APP_NAME":null,"config_app_name":"Laravel","env_DB_DATABASE":null,"config_db":"rvl_laravel","config_cached":true}
$ curl -s localhost:8987/envcheck # after php artisan config:clear
{"env_APP_NAME":"Laravel","config_app_name":"Laravel","env_DB_DATABASE":"rvl_laravel","config_db":"rvl_laravel","config_cached":false}
Which is a rule everyone repeats and nobody escapes. Mine arrived as six failing tests, one of them reading
Failed asserting that 501 is identical to 1. PHPUnit sets DB_DATABASE=rvl_laravel_test in
phpunit.xml, config/database.php reads it through env(), and bootstrap/cache/config.php had
been written an hour earlier with 'database' => 'rvl_laravel' baked into it. The suite ran against
the development database and its 500 seeded rows, green config, no warning, no error. php artisan
config:clear and all 12 tests passed. Rails has no equivalent hazard here for the dull reason that
it never caches ENV into a file.
The same endpoint on both, and the run that was wrong
The first benchmark was not a fair one, and it is worth printing because it is exactly how these
comparisons get faked. php artisan serve runs the PHP CLI server, and php -i on this machine reports
opcache.enable_cli => Off => Off, so Laravel was recompiling 476 PHP files from source on every
request. That run gave Rails 448.79 requests per second against 71.51.
Turning OPcache on moved Laravel to 72.90. Which is the part I did not expect: opcode compilation was
worth 1.9 percent, and the boot cost that php artisan optimize addresses was worth 64 percent. If
you go looking for PHP throughput, the config and route caches are where it is, not the opcode cache.
The honest run, both applications in production mode, one process and one thread each,
ab -n 2000 -c 12 after a 400-request warm-up, with OPcache on and php artisan optimize applied:
=== RAILS 1 worker 1 thread, -c 12 ===
Document Length: 3932 bytes
Complete requests: 2000
Failed requests: 0
Requests per second: 470.29 [#/sec] (mean)
Time per request: 2.126 [ms] (mean, across all concurrent requests)
Total: 3 25 3.2 25 50
=== LARAVEL opcache + optimize, -c 12 ===
Document Length: 3807 bytes
Complete requests: 2000
Failed requests: 0
Requests per second: 119.54 [#/sec] (mean)
Time per request: 8.366 [ms] (mean, across all concurrent requests)
Total: 13 100 7.3 99 134
At one connection instead of twelve, 2.482 ms against 8.796 ms per request. Set beside the 7.505 ms median boot measured earlier, that says the gap is almost entirely the boot and almost not at all the ORM or the query.
Four processes each, because one process is not how either of these runs in production. Rails with
WEB_CONCURRENCY=4 RAILS_MAX_THREADS=1, PHP with PHP_CLI_SERVER_WORKERS=4:
| 1 process | 4 processes | RSS per process | |
|---|---|---|---|
| Rails 8.1.3.1, Puma 8.0.2 | 470.29 | 1946.29 | 98.3 MB |
| Laravel 13.33.0, PHP 8.5.11 | 119.54 | 685.94 | 25.6 MB |
Laravel scales better across processes (5.7x from 4 workers, against 4.1x) for the reason the rest of this page has been describing: each process is small and shares nothing, so adding one adds a whole machine's worth of independence. And four Puma workers are 393 MB where four PHP workers are 102 MB. If you are sizing a 512 MB container, that number matters more than the throughput one.
Getting the four-worker Laravel number took one detour. PHP_CLI_SERVER_WORKERS=4 php artisan serve
produced 116.23 requests per second, unchanged, and the reason was printed in the server log the whole
time:
WARN Unable to respect the `PHP_CLI_SERVER_WORKERS` environment variable without the
`--no-reload` flag. Only creating a single server.
ServeCommand drops the variable unless --no-reload is passed. Running php -S directly against
public/index.php produced the 685.94 above, out of five processes.
Three caveats on all of it, and they are not small. The PHP built-in server is not a production SAPI; php-fpm is, and php-fpm is not measured anywhere on this page. Neither is Laravel Octane, which is the thing that would change these numbers most. And this is one read-only JSON endpoint on loopback with no YJIT on the Ruby side and no JIT on the PHP side, which is a statement about two frameworks' request overhead and not about your application.
Soft deletes, and the one generator each ships that the other does not
Laravel has soft deletes in the box. Add $table->softDeletes() to a migration, use SoftDeletes to
the model, and delete() stops deleting:
$post->delete();
$this->assertSame(1, DB::table('posts')->count());
$this->assertSame(0, SoftPost::count());
$this->assertSame(1, SoftPost::withTrashed()->count());
$this->assertSame(
'select * from "posts" where "posts"."deleted_at" is null',
SoftPost::query()->toSql()
);
Active Record has nothing equivalent, and grep -rln "soft_delete" activerecord-8.1.3.1/lib/ returns
nothing. Paranoia and Discard are gems you pick, version and upgrade.
Rails has the reverse case in authentication. bin/rails generate authentication --pretend writes 19
files, 13 of them outside test/, including app/controllers/concerns/authentication.rb, app/models/session.rb,
app/mailers/passwords_mailer.rb and a CreateSessions migration. The Laravel 13 skeleton ships
App\Models\User and a users table and zero auth routes: php artisan route:list on a fresh install
shows 4 routes, which are /, /up and two storage/{path} routes. The starter kits that fill that in are separate packages
chosen at install time.
Queues are the same story on both sides
Both ship a database-backed queue and both need a second process to run it. Laravel's
SlowThing::dispatch(7) returned in 0.032 s and left one row in jobs; php artisan queue:work --once
took it in another pid and logged App\Jobs\SlowThing .. 213.28ms DONE. Rails' perform_later
returned in 0.081 s and left one solid_queue_jobs row; bin/jobs finished it in another pid.
The difference is surface area. Laravel's queue is three tables, jobs, job_batches and
failed_jobs. Solid Queue is 13, including solid_queue_semaphores for concurrency limits and
solid_queue_recurring_tasks for the scheduler. Laravel puts the scheduler somewhere else entirely,
in routes/console.php plus a cron entry calling schedule:run.
What each one drags in
composer create-project laravel/laravel installed 109 packages into a 69 MB vendor/ and wrote 62
files outside it. rails new --skip-bundle wrote 78 files, and bundling them pulls 123 gems totalling
157 MB. php artisan list --raw prints 122 commands; bin/rails -T prints 63 tasks and
bin/rails generate --help lists 20 Rails generators. Neither framework is the small one.
One of those 109 packages is worth naming. laravel/pao, a dev dependency of the default skeleton,
describes itself as "Agent-optimized output for PHP testing tools" and pulls laravel/agent-detector,
which maps the environment variable CLAUDECODE to KnownAgent::Claude on line 19 of
AgentDetector.php. Every vendor/bin/phpunit run in this session printed a single line of JSON
instead of PHPUnit's usual output until I ran it with env -u CLAUDECODE. Whatever you think of that,
it is in the default composer.json of Laravel 13 and it is not in Rails.
The call, and what would change it
Pick Rails when a request does real work. Everything measured above that favours it traces back to a process that is already warm: 2.48 ms against 8.80 ms on the same endpoint, a query cache that exists, attributes typed from the table, and the whole vocabulary of in-process state that PHP cannot offer without Octane. The moment your application is doing more per request than one SELECT and a render, the fixed 7.5 ms tax on the PHP side stops being the interesting part and the availability of in-process memoisation starts being it.
Pick Laravel when the constraint is the team or the host. 25.6 MB per process against 98.3 MB is a real hosting difference, shared PHP hosting and per-request serverless are places Rails genuinely does not go, and a team that writes PHP well will out-ship a team learning Ruby by a margin no framework benchmark reaches. Soft deletes and validation-in-the-request are both defensible designs that Rails does not offer, and the container is a genuinely different way to wire an application that some codebases want.
What would change this: Octane, or FrankenPHP, becoming what a default Laravel deployment looks like rather than an upgrade you choose. That is the one change that would delete most of this page's performance argument, and I did not measure it, so treat every throughput number here as a statement about the default deployment and nothing more. On the other side, model-level validation landing in Eloquent would delete the sharpest correctness argument, and it has been declined often enough that I would not plan around it.
What should not change your mind is the throughput table. A 3.9x on one JSON endpoint is worth less than the language your team already writes, and the argument in Ruby on Rails performance comparison applies to both frameworks equally: the framework is almost never the slow part of a slow application.
What this page does not cover
No php-fpm, no nginx, no Octane, no FrankenPHP, no Swoole. Every PHP number here came through the built-in CLI server, which is the configuration least flattering to Laravel and the only one I could run without standing up a second web server. Nothing here is a production deployment benchmark for either side.
Also absent: Blade against ERB and Hotwire, which is where a large part of the real difference between these two frameworks lives; Livewire and Inertia; the service container and constructor injection, which is Laravel's biggest structural idea and gets no measurement here; authorisation policies; Laravel's events and listeners against Active Record callbacks; anything about Rails multi-database setups or Laravel's read/write connections; and any comparison of hiring markets, package ecosystems or hosting prices, none of which can be measured from a laptop.
Two things I could not verify and therefore did not claim. No third-party cross-framework benchmark is
quoted here, for the reason given on the performance page: techempower.com/benchmarks serves an empty
document to a fetch. And nothing about either project's roadmap, because a roadmap is not a primary
source about what the code does today.
Comments
No comments yet. Be the first.