Why I choose Rails or Django over PHP for software that has to last

TL;DR

Difficulty: Intermediate

My view: for software that has to live for years, Rails and Django hold up better than PHP, because the safe and maintainable way is the default.

  • Conventions make every Rails or Django project predictable. Plain PHP has none unless the team builds them.
  • Security defaults: escaping, bound parameters, forgery tokens and mass assignment protection are on by default in Rails, Django and, to be fair, Laravel. In plain PHP they are opt in.
  • Testing is one integrated default in Rails. The PHP pieces exist (Laravel factories, HTTP tests, Dusk, Pest, Behat) but a plain PHP project starts without them.
  • The long run turns on how much faster change is with tests and conventions. Estimate that number for your own project.

This is my opinion, with the facts checked: for software that has to last, I choose Rails or Django over PHP. I walk through conventions, security defaults, scaling tools and testing, show where plain PHP leaves you on your own and where Laravel has closed the gap, and price the long run on an invented Waterford lettings agency.

Where I stand, and what this is not

This is an opinion piece, and the opinion is mine. For software that has to live for years, I choose Ruby on Rails or Django over PHP, and I would tell a client the same. Not because of syntax or tribe. Because of what happens to a codebase in year two and year three, when the people who wrote it have moved on and the business still needs it to change.

I have been building web applications for more than 14 years. I have worked with PHP, with Rails and with Django, and I have seen projects in all of them succeed and fail. The pattern I see is that Rails and Django projects hold up better over the long haul, because both frameworks were built around maintainability from the start: conventions, safe defaults and a testing culture that comes in the box.

Two things this article is not. It is not a claim that good software cannot be built in PHP. It can, and Laravel in particular has closed a lot of the gaps I describe below, which I say at each point where it is true. I compare the frameworks more evenly, Laravel included, in choosing the right web framework. And it is not about AI assisted development, which carries its own risks when abused. Here I assume people who write and understand the code.

The speed of week one is not the speed of year three

PHP's strongest argument is how quickly a project starts. Fair enough: a basic create, read, update and delete application can be up in plain PHP very quickly, and the first week or two genuinely are fast.

My argument is that this speed is borrowed. Every shortcut in week one is a cost in month three, and the bill arrives when you need to:

  • add a feature that touches existing code,
  • fix a bug without breaking something else,
  • bring in a new developer who has to understand the codebase,
  • upgrade a dependency,
  • handle more traffic,
  • pass a security audit.

In a plain PHP codebase without a framework, each of these depends on whatever the original authors decided. In Rails or Django, much of it was decided by the framework, so the codebase is predictable.

Worked example: the same change, two codebases

Dunmore Lettings is an invented lettings agency in Waterford, at dunmorelettings.example, with a landlord portal. The change request: "Landlords should see the deposit status on each tenancy."

In the plain PHP version, a new developer first has to find where tenancies are loaded. There are three places, each with its own SQL. They add the field to two of them and miss the third, which only the monthly statement page uses. Nobody notices until a landlord does.

In the Rails version, tenancies are loaded through one Tenancy model in app/models/tenancy.rb, deposit status becomes one attribute, and the test suite fails on the statement page until it shows the new field. The change is the same. What differs is how many places a person has to know about.

Convention over configuration, and why it matters

"Convention over configuration" is one of the pillars of the Rails doctrine, the short statement of the ideas Rails is built on.1 Instead of every team deciding where files go, how things are named and how components talk to each other, the framework decides once.

That sounds minor until you work on a codebase where:

  • every developer had their own folder structure,
  • tables are named inconsistently (user_accounts, users, tbl_user and UserAccount in one database),
  • form validation is done three different ways,
  • routing is scattered across several files,
  • some controllers run to thousands of lines because nobody agreed how to extract shared logic.

In Rails, models live in app/models, controllers in app/controllers, views in app/views. Tables are plural, models singular. Associations follow predictable names. Routes are defined in one file. The pay off:

  • Any Rails developer can find their way around any Rails project. Onboarding is about the business, not the folder structure.
  • Code reviews are about the code, not about where things should go.
  • Tooling works, because it can assume the structure.
  • Refactoring is safer, because the boundaries between components are clear.

Django has the same philosophy with its apps, URL configuration and ORM conventions. Laravel brings strong conventions of its own to PHP, which is a large part of why it is the PHP framework I respect most. Plain PHP has none of this unless the team builds it.

Security: on by default, or opt in

This is where defaults matter most. In plain PHP, the safe way is available but optional. In Rails and Django, the safe way is what happens unless you go out of your way. In Laravel, much the same is true, and I say so in each case.

SQL injection

Here is how easy it is to write vulnerable plain PHP:

<?php
// Vulnerable: user input pasted straight into SQL
$username = $_POST['username'];
$query = "SELECT * FROM users WHERE username = '" . $username . "'";
$result = mysqli_query($conn, $query);

An attacker submits admin' OR '1'='1 as the username, the condition is always true, and the query returns every user. I still see this pattern in production PHP codebases in 2026.

PHP itself has the fix, prepared statements in PDO, and the PHP manual says that an application that uses them exclusively for its inputs is protected from SQL injection, as long as no other part of the query is built from unescaped input.2 Laravel's query builder uses PDO parameter binding for you.3 The difference is that in plain PHP nothing stops the string version.

In Rails, the standard finders escape their input for you:4

user = User.find_by(username: params[:username])     # safe
User.where("username = ?", params[:username])         # safe, the ? is a bound parameter
User.where("username = '#{params[:username]}'")       # NOT safe: string interpolation

The older version of this article said you literally cannot write an injection with the standard query interface. That was wrong, and the third line shows why: the Rails security guide warns that SQL fragments such as where("...") need the protection applied by hand, with placeholders.4 Rails makes the safe form the obvious one, it does not make the unsafe one impossible. Django is the same: its querysets are built with query parameterisation.5

Worked example: the payload, step by step

A Dunmore landlord types admin' OR '1'='1 into the login box of the plain PHP portal. The query string becomes:

SELECT * FROM users WHERE username = 'admin' OR '1'='1'

The first quote in the input closes the string, OR '1'='1' is always true, and the final quote is supplied by the original code. With a bound parameter, the database receives the whole input as one value, so it looks for a user literally called admin' OR '1'='1 and finds none.

Cross site scripting

Plain PHP will echo user input straight into HTML:

<?php
// Vulnerable: input goes straight into the page
echo "<h1>Welcome, " . $_GET['name'] . "</h1>";

A name containing a <script> tag runs in the visitor's browser, which is how session cookies get stolen. In plain PHP the fix is to remember htmlspecialchars on every output. In Laravel, Blade's {{ }} sends output through htmlspecialchars automatically.6 In Rails views, unsafe strings are escaped automatically:7

<h1>Welcome, <%= @user.name %></h1>

If @user.name contains a script tag, it is shown as harmless text. You have to opt out with raw or html_safe, and the Rails guide is clear that html_safe performs no escaping at all, it is only an assertion that the string is safe.7 In code review, either one is a line to look at twice. Django's templates escape by default too.5

Cross site request forgery

A forged request is a form on another site that submits to yours, using your visitor's logged in session. Rails protects against it by default: new applications set default_protect_from_forgery, which puts a security token in every form Rails generates and rejects requests without it.4

<%= form_with model: @user do |f| %>
  <%= f.text_field :email %>
  <%= f.submit "Update" %>
<% end %>

The generated form carries a hidden token, without anyone remembering to add it. Django has built in protection too, as long as you have it enabled and used.5 In plain PHP, you build this yourself, on every form. The older version of this article stopped there, and that was unfair to Laravel: its request forgery middleware is in the web middleware group by default.8

Mass assignment

The subtle one. Take an update that passes everything submitted straight to the model. If an attacker adds admin=1 to the form data, they may just have made themselves an admin.

Rails refuses to do this. Passing raw parameters to a model raises ActiveModel::ForbiddenAttributesError, so you must list what is allowed:9

def update
  @user.update(user_params)
end

private

def user_params
  params.expect(user: [:email, :name, :password])   # anything else is dropped
end

Laravel's Eloquent models are protected against mass assignment by default as well, through a $fillable or $guarded list on the model.10 In plain PHP with a hand written data layer, the protection is whatever its author remembered.

Growing without pain

Every application that succeeds needs to handle more traffic, more data and more complexity. Here the frameworks give you tools; plain PHP gives you a blank page.

The N plus one query

The classic performance mistake is loading a list and then querying the database once for each item in it.

# One query for the landlords, then one more per landlord
Landlord.all.each { |landlord| puts landlord.tenancies.size }

# Two queries in total: the landlords, then all their tenancies
Landlord.includes(:tenancies).each { |landlord| puts landlord.tenancies.size }

Note size, not count. On an association, count always runs a COUNT query in the database, while size uses the records that are already loaded.11 The older version of this article used count in its "fixed" example, which quietly brought the extra queries back. Rails can also refuse lazy loading altogether with strict_loading, so the mistake raises an error in development instead of slowing production.12

Worked example: Dunmore's statement run

Dunmore has 1,000 landlords. The first loop sends 1+1,000=1,0011 + 1{,}000 = 1{,}001 queries. The second sends two. If each round trip to the database takes 2 milliseconds (an assumption for the example), the first loop spends about 1,001×2=2,0021{,}001 \times 2 = 2{,}002 milliseconds, two seconds, just waiting, and the second about 4 milliseconds. The page is the same. The wait is 500 times shorter.

Background jobs

Slow work, such as sending email or generating a statement, belongs outside the web request. Rails has Active Job built in:

class SendWelcomeEmailJob < ApplicationJob
  queue_as :default

  def perform(user_id)
    UserMailer.welcome(User.find(user_id)).deliver_now
  end
end

SendWelcomeEmailJob.perform_later(user.id)   # returns at once, the job runs in the background

The same job code runs on whichever queue backend you configure. Laravel has its own queues, which I give it credit for in the framework comparison.

Caching

Rails has fragment caching in the view, and the nested form people call Russian doll caching:

<% cache @landlord do %>
  <% @landlord.tenancies.each do |tenancy| %>
    <% cache tenancy do %>
      <%= render tenancy %>
    <% end %>
  <% end %>
<% end %>

Each cache entry carries a version derived from the record, so when a record is updated its fragment expires. One detail the older version left out: for the outer fragment to expire when an inner record changes, the inner model needs belongs_to :landlord, touch: true, so that updating a tenancy also updates its landlord's timestamp.13 With that line, no manual invalidation is needed.

Database migrations

Migrations put the database schema under version control:

class AddDepositStatusToTenancies < ActiveRecord::Migration[8.1]
  def change
    add_column :tenancies, :deposit_status, :string, default: "pending"
    add_index :tenancies, :deposit_status
  end
end

bin/rails db:migrate applies it, bin/rails db:rollback reverses it, and every developer's database stays in step. I have seen PHP projects where schema changes were announced in a chat message and applied by hand. That is a plain PHP problem, not a Laravel one: Laravel's migrations do the same job.14

Testing is where the long run is won

This is the part that matters most to me, and where the defaults differ most.

The business case, without the folklore

You will often see a neat multiplier for how much more a bug costs the later it is found. I am not going to quote one, because I have not found a source for any particular number that I would stand behind. The direction is not in doubt, though. A bug caught while writing the code costs the person writing it a few minutes. Caught in review, it costs two people. Caught by testers, it has to be written up, reproduced, fixed and tested again. Caught by customers, it costs their trust as well, and a bug that leaks data adds legal and regulatory costs on top.

Automated tests catch bugs at the first stage, every time the code changes. That is the whole case.

Test driven development

Test driven development turns the usual order round: write the test, watch it fail, write the least code that makes it pass, then tidy up. Red, green, refactor.

# test/models/user_test.rb (written first, so it fails)
require "test_helper"

class UserTest < ActiveSupport::TestCase
  test "rejects an invalid email" do
    user = User.new(name: "Dave", email: "not an email")
    assert_not user.save
  end

  test "saves a valid email" do
    user = User.new(name: "Dave", email: "dave@example.com")
    assert user.save
  end
end

# app/models/user.rb (the least code to make it pass)
class User < ApplicationRecord
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end

What it buys: the test states the intent before the code exists, the code ends up testable because it had to be, and refactoring is safe because the tests tell you what broke.

Behaviour driven development

BDD writes the important journeys as scenarios that a non technical owner can read, and that also run as tests. In Rails the tool is Cucumber, and I wrote a whole article on Cucumber and BDD:

Feature: Landlord registration
  Scenario: Registering with valid details
    Given I am on the registration page
    When I fill in "Email" with "landlord@example.com"
    And I fill in "Password" with "a long passphrase"
    And I fill in "Password confirmation" with "a long passphrase"
    And I press "Create account"
    Then I should see "Your account has been created"
    And a confirmation email should be sent to "landlord@example.com"

  Scenario: Registering with an invalid email
    Given I am on the registration page
    When I fill in "Email" with "not an email"
    And I press "Create account"
    Then I should see "Email is invalid"

When the owner asks what happens if someone registers with an invalid email, you show them the scenario. It is current because it runs in every build.

RSpec, factories and system tests

Many Rails teams use RSpec for its readable structure, and factory_bot for test data:

# spec/models/order_spec.rb
RSpec.describe Order do
  let(:product) { create(:product, price: BigDecimal("29.99")) }

  it "sums the item prices" do
    order = create(:order)
    order.items.create!(product: product, quantity: 2)
    order.items.create!(product: create(:product, price: BigDecimal("10.00")), quantity: 1)

    expect(order.total).to eq(BigDecimal("69.98"))   # 2 x 29.99 + 10.00
  end

  it "never goes below zero with a fixed discount" do
    order = create(:order, discount_fixed: BigDecimal("100.00"))
    order.items.create!(product: product, quantity: 1)

    expect(order.total).to eq(0)
  end
end

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
    password { "a long passphrase" }

    trait :admin do
      admin { true }
    end
  end
end

admin = create(:user, :admin, email: "office@dunmorelettings.example")

Money is a BigDecimal throughout, because floating point numbers cannot store most cents exactly. And traits come before keyword attributes in create: the older version had create(:user, name: "VIP", :with_subscription), which is a Ruby syntax error.

Rails also ships system tests, which drive a real browser through Capybara:

# test/system/checkout_test.rb
class CheckoutTest < ApplicationSystemTestCase
  test "a signed in landlord pays the listing fee" do
    sign_in create(:user)
    visit new_listing_path
    fill_in "Address line 1", with: "14 Dunmore Road"
    fill_in "Town or city", with: "Waterford"
    fill_in "County", with: "Co. Waterford"
    fill_in "Eircode", with: "X91 K2P8"
    click_on "Pay and publish"

    assert_text "Your listing is live"
  end
end

Continuous integration

Tests only help if they run. A minimal GitHub Actions workflow:

# .github/workflows/test.yml
name: Tests
on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: postgres
        ports: ["5432:5432"]
        options: --health-cmd pg_isready --health-interval 10s --health-retries 5
    env:
      RAILS_ENV: test
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - run: bin/rails db:create db:schema:load
      - run: bin/rails test
      - run: bin/rails test:system

The older version ran RSpec with continue-on-error: true, which lets a failing suite pass the build. Remove it. And a failing check only blocks a merge if the repository's branch protection makes it a required status check, so switch that on too.15

Testing in PHP, fairly

The older version of this article said PHP testing has no factories, no standard way to test controllers, no Cucumber equivalent and no browser tests. For plain PHP with PHPUnit alone that is roughly how it feels. For the PHP ecosystem as a whole it was wrong, and I correct it here:

  • Laravel ships model factories for test data.16
  • Laravel has a fluent API for HTTP tests against routes and controllers, written in Pest or PHPUnit.17
  • Laravel Dusk drives a browser, and Laravel now recommends Pest for browser testing in new projects.18
  • Behat is the PHP implementation of Cucumber, for BDD in plain language.19

So my view narrows to this. In Rails, unit tests, system tests, fixtures, the test database and the generators are one integrated default, in the framework itself, and the culture expects you to use them. In the PHP world the same pieces exist, mostly around Laravel, but a plain PHP project starts without them, and when testing has to be assembled first, it is the first thing skipped "to save time".

The long run, an invented comparison

Two invented projects, built for the same Dunmore portal, followed for three years. It is a story, not data, and it compares a culture as much as a language: a Laravel project with the same testing discipline would look like the second one.

Project A, plain PHP with no tests. Month one is fast. By month three, bug fixes introduce new bugs. By month six a big change is due and the team is afraid to touch anything. By month 12 the two original developers have left and their replacements take months to learn the codebase. By month 18 an audit finds injection and forgery holes. By month 36 someone proposes a rewrite.

Project B, Rails with tests from day one. Month one feels slower, because tests are written alongside features. By month three the suite catches regressions before release. By month six the big change takes a week, because the tests show what it affects. By month 12 new developers read the tests to learn the features. By month 18 the audit finds that the framework defaults covered the common holes. By month 36 the codebase is still changeable.

Worked example: pricing the difference

To make the story concrete, here are invented numbers for the two Dunmore projects, all of them assumptions for the example. Both cost €60,000 to build. Project B costs 20% more in the first year because of the tests, so €72,000. After that, each year brings €30,000 of change requests at Project A's speed. Project B delivers the same changes for 30% less effort, because changes are predictable and safe.

  • Project A over three years: 60,000+30,000+30,000=€120,00060{,}000 + 30{,}000 + 30{,}000 = €120{,}000.
  • Project B over three years: 72,000+21,000+21,000=€114,00072{,}000 + 21{,}000 + 21{,}000 = €114{,}000.

Project B costs €6,000 less over three years on these assumptions, and the gap widens every further year by €9,000. Change the 30% to 10% and B ends up more expensive, 72,000+27,000+27,000=€126,00072{,}000 + 27{,}000 + 27{,}000 = €126{,}000. That is the honest shape of the argument: it rests on how much faster change is with tests and conventions, and that is exactly the number to estimate for your own project.

What I would tell a client

Starting a new project that has to last, I recommend Rails or Django, because the conventions, the safe defaults and the testing culture compound over the years. If the team already speaks PHP, Laravel is a sound choice and I would rather see a well tested Laravel application than a badly tested Rails one. What I would not choose for anything long lived is plain PHP without a framework, where every safety net has to be remembered by hand.

Rapid development frameworks earned their name not because they let you write code quickly, but because they keep code quick to change. And if the code was generated by AI without anyone understanding it, the framework matters less than the fact that nobody can maintain it.

Support windows to check before you choose

Whatever you pick, plan the upgrades. When I checked in November 2026: each PHP branch gets two years of active support and then two more of security fixes only. PHP 8.2 gets security fixes until 31 December 2026, and the newest branch, PHP 8.5, released on 20 November 2025, until 31 December 2029.20 Laravel gives each major release 18 months of bug fixes and two years of security fixes. Laravel 13, released on 17 March 2026, needs PHP 8.3 to 8.5 and has security fixes until 17 March 2028.21 Rails gives each minor release two years of security fixes, and Rails 8.1 is covered until 10 October 2027.22 Django 5.2, the current long term support release, is supported until April 2028.23

Footnotes

  1. David Heinemeier Hansson, The Rails Doctrine, rubyonrails.org, https://rubyonrails.org/doctrine

  2. The PHP Group, Prepared statements and stored procedures, PHP Manual, php.net, https://www.php.net/manual/en/pdo.prepared-statements.php

  3. Laravel, Database: query builder, Laravel 13.x documentation, laravel.com, https://laravel.com/docs/13.x/queries

  4. Ruby on Rails Guides, Securing Rails Applications (SQL injection, cross site request forgery), guides.rubyonrails.org, https://guides.rubyonrails.org/security.html

  5. Django Software Foundation, Security in Django, Django documentation, https://docs.djangoproject.com/en/stable/topics/security/

  6. Laravel, Blade templates, Laravel 13.x documentation, laravel.com, https://laravel.com/docs/13.x/blade

  7. Ruby on Rails Guides, Active Support Core Extensions, output safety, guides.rubyonrails.org, https://guides.rubyonrails.org/active_support_core_extensions.html

  8. Laravel, CSRF protection, Laravel 13.x documentation, laravel.com, https://laravel.com/docs/13.x/csrf

  9. Ruby on Rails Guides, Action Controller Overview, strong parameters, guides.rubyonrails.org, https://guides.rubyonrails.org/action_controller_overview.html

  10. Laravel, Eloquent: getting started, mass assignment, Laravel 13.x documentation, laravel.com, https://laravel.com/docs/13.x/eloquent

  11. Ruby on Rails API, ActiveRecord::Associations::CollectionProxy, count and size, api.rubyonrails.org, https://api.rubyonrails.org/classes/ActiveRecord/Associations/CollectionProxy.html

  12. Ruby on Rails Guides, Active Record Query Interface, eager loading and strict_loading, guides.rubyonrails.org, https://guides.rubyonrails.org/active_record_querying.html

  13. Ruby on Rails Guides, Caching with Rails, Russian doll caching, guides.rubyonrails.org, https://guides.rubyonrails.org/caching_with_rails.html

  14. Laravel, Database: migrations, Laravel 13.x documentation, laravel.com, https://laravel.com/docs/13.x/migrations

  15. GitHub, About protected branches, required status checks, GitHub Docs, https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches

  16. Laravel, Eloquent: factories, Laravel 13.x documentation, laravel.com, https://laravel.com/docs/13.x/eloquent-factories

  17. Laravel, HTTP tests, Laravel 13.x documentation, laravel.com, https://laravel.com/docs/13.x/http-tests

  18. Laravel, Laravel Dusk, Laravel 13.x documentation, laravel.com, https://laravel.com/docs/13.x/dusk

  19. Behat, Behat documentation, docs.behat.org, https://docs.behat.org/en/latest/

  20. The PHP Group, Supported versions, php.net, accessed 8 November 2026, https://www.php.net/supported-versions.php

  21. Laravel, Release notes, support policy, Laravel 13.x documentation, laravel.com, accessed 8 November 2026, https://laravel.com/docs/13.x/releases

  22. Ruby on Rails, Maintenance policy for Ruby on Rails, rubyonrails.org, accessed 8 November 2026, https://rubyonrails.org/maintenance

  23. Django Software Foundation, Download: supported versions and roadmap, djangoproject.com, accessed 8 November 2026, https://www.djangoproject.com/download/

If you are deciding what to build on, or living with a PHP codebase that has become hard to change, bring it to the free workshop. We will look at what it would take to keep it, harden it or move it, and the first two weeks of work are on me.