Warum ich für langlebige Software Rails oder Django statt PHP wähle

Kurz gesagt

Niveau: Fortgeschritten

Meine Sicht: Für Software, die jahrelang leben muss, halten Rails und Django besser als PHP, weil der sichere und wartbare Weg die Voreinstellung ist.

  • Konventionen machen jedes Projekt in Rails oder Django vorhersagbar. Reines PHP hat keine, es sei denn, das Team baut sie.
  • Sichere Voreinstellungen: Maskierung, gebundene Parameter, Tokens gegen gefälschte Anfragen und Schutz vor Mass Assignment sind in Rails, Django und, fairerweise, Laravel standardmäßig an. In reinem PHP sind sie freiwillig.
  • Tests sind in Rails eine integrierte Voreinstellung. Die Teile gibt es auch in PHP (Factories und HTTP Tests in Laravel, Dusk, Pest, Behat), aber ein Projekt in reinem PHP startet ohne sie.
  • Die lange Strecke hängt daran, wie viel schneller Änderungen mit Tests und Konventionen gehen. Schätz diese Zahl für dein eigenes Projekt.

Das ist meine Meinung, mit geprüften Fakten: Für Software, die halten muss, wähle ich Rails oder Django statt PHP. Ich gehe Konventionen, sichere Voreinstellungen, Werkzeuge zum Skalieren und Tests durch, zeige, wo reines PHP dich allein lässt und wo Laravel die Lücke geschlossen hat, und rechne die lange Strecke an einer erfundenen Vermietungsagentur in Hamburg durch.

Wo ich stehe, und was das hier nicht ist

Das ist ein Meinungsbeitrag, und die Meinung ist meine. Für Software, die jahrelang leben muss, wähle ich Ruby on Rails oder Django statt PHP, und genau das würde ich auch einem Kunden sagen. Nicht wegen der Syntax oder aus Lagerdenken. Sondern wegen dem, was mit einer Codebasis im zweiten und dritten Jahr passiert, wenn die Leute, die sie geschrieben haben, weitergezogen sind und das Unternehmen trotzdem Änderungen braucht.

Ich baue seit mehr als 14 Jahren Webanwendungen. Ich habe mit PHP gearbeitet, mit Rails und mit Django, und ich habe in allen dreien Projekte gelingen und scheitern sehen. Das Muster, das ich sehe: Projekte in Rails und Django halten auf lange Sicht besser, weil beide Frameworks von Anfang an um die Wartbarkeit herum gebaut wurden, mit Konventionen, sicheren Voreinstellungen und einer Testkultur, die gleich mitgeliefert wird.

Zwei Dinge ist dieser Artikel nicht. Er ist keine Behauptung, dass sich in PHP keine gute Software bauen lässt. Das geht, und vor allem Laravel hat viele der Lücken geschlossen, die ich unten beschreibe, was ich an jeder Stelle sage, an der es stimmt. Ausgewogener vergleiche ich die Frameworks, Laravel eingeschlossen, in das richtige Web Framework wählen. Und es geht nicht um KI gestützte Entwicklung, die ihre eigenen Risiken hat, wenn man sie missbraucht. Hier setze ich Menschen voraus, die den Code schreiben und verstehen.

Das Tempo der ersten Woche ist nicht das Tempo des dritten Jahres

Das stärkste Argument für PHP ist, wie schnell ein Projekt startet. Stimmt: Eine einfache Anwendung zum Anlegen, Lesen, Ändern und Löschen steht in reinem PHP sehr schnell, und die ersten ein, zwei Wochen sind wirklich flott.

Mein Argument ist, dass dieses Tempo geliehen ist. Jede Abkürzung in der ersten Woche ist eine Rechnung im dritten Monat, und sie kommt, wenn du:

  • eine Funktion ergänzen musst, die bestehenden Code berührt,
  • einen Fehler beheben willst, ohne etwas anderes kaputtzumachen,
  • eine neue Entwicklerin holst, die die Codebasis verstehen muss,
  • eine Abhängigkeit aktualisieren musst,
  • mehr Traffic verkraften musst,
  • ein Sicherheitsaudit bestehen musst.

In einer reinen PHP Codebasis ohne Framework hängt jeder dieser Punkte davon ab, was die ursprünglichen Autoren entschieden haben. In Rails oder Django hat vieles davon das Framework entschieden, die Codebasis ist also vorhersagbar.

Beispiel: dieselbe Änderung, zwei Codebasen

Elbkontor ist eine erfundene Vermietungsagentur in Hamburg, unter elbkontor.example, mit einem Portal für Vermieter. Der Änderungswunsch: "Vermieter sollen bei jedem Mietverhältnis den Status der Kaution sehen."

In der Fassung mit reinem PHP muss ein neuer Entwickler zuerst herausfinden, wo Mietverhältnisse geladen werden. Es sind drei Stellen, jede mit eigenem SQL. Er ergänzt das Feld an zweien und übersieht die dritte, die nur die Seite mit der monatlichen Abrechnung nutzt. Niemand merkt es, bis ein Vermieter es merkt.

In der Fassung mit Rails werden Mietverhältnisse über ein einziges Modell Tenancy in app/models/tenancy.rb geladen, der Status der Kaution wird ein Attribut, und die Testsuite scheitert auf der Abrechnungsseite, bis sie das neue Feld zeigt. Die Änderung ist dieselbe. Was sich unterscheidet, ist, wie viele Stellen ein Mensch kennen muss.

Konvention vor Konfiguration, und warum das zählt

"Convention over configuration" ist eine der Säulen der Rails Doctrine, der kurzen Darstellung der Ideen, auf denen Rails gebaut ist.1 Statt dass jedes Team entscheidet, wo Dateien liegen, wie Dinge heißen und wie Komponenten miteinander sprechen, entscheidet das Framework einmal.

Das klingt nach einer Kleinigkeit, bis du an einer Codebasis arbeitest, in der:

  • jeder Entwickler seine eigene Ordnerstruktur hatte,
  • Tabellen uneinheitlich heißen (user_accounts, users, tbl_user und UserAccount in einer Datenbank),
  • Formulare auf drei verschiedene Arten validiert werden,
  • das Routing über mehrere Dateien verstreut ist,
  • manche Controller Tausende Zeilen lang sind, weil sich niemand geeinigt hat, wie gemeinsame Logik ausgelagert wird.

In Rails liegen Modelle in app/models, Controller in app/controllers, Views in app/views. Tabellen stehen im Plural, Modelle im Singular. Assoziationen folgen vorhersagbaren Namen. Routen stehen in einer Datei. Was das bringt:

  • Jeder Rails Entwickler findet sich in jedem Rails Projekt zurecht. Beim Einarbeiten geht es um das Geschäft, nicht um die Ordnerstruktur.
  • Code Reviews drehen sich um den Code, nicht darum, wo etwas hingehört.
  • Werkzeuge funktionieren, weil sie die Struktur voraussetzen können.
  • Refactoring ist sicherer, weil die Grenzen zwischen den Komponenten klar sind.

Django folgt derselben Philosophie mit seinen Apps, der URL Konfiguration und den Konventionen seines ORM. Laravel bringt eigene starke Konventionen nach PHP, und das ist ein großer Teil des Grundes, warum es das PHP Framework ist, das ich am meisten respektiere. Reines PHP hat nichts davon, es sei denn, das Team baut es selbst.

Sicherheit: standardmäßig an, oder nur auf Wunsch

Hier zählen Voreinstellungen am meisten. In reinem PHP ist der sichere Weg verfügbar, aber freiwillig. In Rails und Django ist der sichere Weg das, was passiert, solange du dich nicht aktiv dagegen entscheidest. In Laravel gilt weitgehend dasselbe, und ich sage es jeweils dazu.

SQL Injection

So leicht schreibt sich angreifbares reines PHP:

<?php
// Angreifbar: Nutzereingabe direkt ins SQL kopiert
$username = $_POST['username'];
$query = "SELECT * FROM users WHERE username = '" . $username . "'";
$result = mysqli_query($conn, $query);

Ein Angreifer schickt admin' OR '1'='1 als Benutzernamen, die Bedingung ist immer wahr, und die Abfrage liefert alle Nutzer. Dieses Muster sehe ich 2026 immer noch in PHP Codebasen in Produktion.

PHP selbst hat die Lösung, Prepared Statements in PDO, und das PHP Handbuch sagt, dass eine Anwendung, die für ihre Eingaben ausschließlich Prepared Statements nutzt, vor SQL Injection geschützt ist, solange kein anderer Teil der Abfrage aus nicht maskierten Eingaben gebaut wird.2 Der Query Builder von Laravel nutzt die Parameterbindung von PDO für dich.3 Der Unterschied: In reinem PHP hält nichts die Fassung mit zusammengesetztem String auf.

In Rails maskieren die üblichen Finder ihre Eingaben für dich:4

user = User.find_by(username: params[:username])     # sicher
User.where("username = ?", params[:username])         # sicher, das ? ist ein gebundener Parameter
User.where("username = '#{params[:username]}'")       # NICHT sicher: Stringinterpolation

Die ältere Fassung dieses Artikels hat behauptet, mit der normalen Abfrageschnittstelle könne man buchstäblich keine Injection schreiben. Das war falsch, und die dritte Zeile zeigt, warum: Der Sicherheitsleitfaden von Rails warnt, dass SQL Fragmente wie where("...") den Schutz von Hand brauchen, mit Platzhaltern.4 Rails macht die sichere Form zur naheliegenden, die unsichere macht es nicht unmöglich. Bei Django ist es genauso: Seine Querysets werden mit parametrisierten Abfragen gebaut.5

Beispiel: die Nutzlast, Schritt für Schritt

Ein Vermieter bei Elbkontor tippt admin' OR '1'='1 in das Anmeldefeld des Portals in reinem PHP. Der Abfragestring wird zu:

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

Das erste Anführungszeichen in der Eingabe schließt den String, OR '1'='1' ist immer wahr, und das letzte Anführungszeichen liefert der ursprüngliche Code. Mit einem gebundenen Parameter bekommt die Datenbank die ganze Eingabe als einen Wert, sie sucht also einen Nutzer, der wörtlich admin' OR '1'='1 heißt, und findet keinen.

Cross Site Scripting

Reines PHP gibt Nutzereingaben direkt ins HTML aus:

<?php
// Angreifbar: die Eingabe landet direkt auf der Seite
echo "<h1>Welcome, " . $_GET['name'] . "</h1>";

Ein Name mit einem <script> Tag läuft im Browser der Besucherin, und so werden Sitzungscookies gestohlen. In reinem PHP besteht die Lösung darin, bei jeder Ausgabe an htmlspecialchars zu denken. In Laravel schickt Blade mit {{ }} die Ausgabe automatisch durch htmlspecialchars.6 In Rails Views werden unsichere Strings automatisch maskiert:7

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

Enthält @user.name ein Script Tag, wird es als harmloser Text angezeigt. Abschalten musst du das ausdrücklich mit raw oder html_safe, und der Rails Leitfaden stellt klar, dass html_safe überhaupt nicht maskiert, es ist nur eine Zusicherung, dass der String sicher ist.7 Im Code Review ist beides eine Zeile, die man zweimal ansieht. Auch die Templates von Django maskieren standardmäßig.5

Cross Site Request Forgery

Eine gefälschte Anfrage ist ein Formular auf einer fremden Seite, das an deine Seite sendet, mit der angemeldeten Sitzung deiner Besucherin. Rails schützt standardmäßig dagegen: Neue Anwendungen setzen default_protect_from_forgery, das jedem von Rails erzeugten Formular ein Sicherheitstoken mitgibt und Anfragen ohne Token ablehnt.4

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

Das erzeugte Formular trägt ein verstecktes Token, ohne dass jemand daran denken muss. Django hat ebenfalls einen eingebauten Schutz, solange du ihn aktiviert hast und nutzt.5 In reinem PHP baust du das selbst, für jedes Formular. Die ältere Fassung dieses Artikels hat hier aufgehört, und das war Laravel gegenüber unfair: Seine Middleware gegen gefälschte Anfragen steckt standardmäßig in der Middleware Gruppe web.8

Mass Assignment

Der subtile Fall. Nimm eine Aktualisierung, die alles Übermittelte direkt an das Modell weitergibt. Hängt ein Angreifer admin=1 an die Formulardaten, hat er sich womöglich gerade selbst zum Admin gemacht.

Rails weigert sich, das zu tun. Rohe Parameter an ein Modell zu übergeben löst ActiveModel::ForbiddenAttributesError aus, du musst also auflisten, was erlaubt ist:9

def update
  @user.update(user_params)
end

private

def user_params
  params.expect(user: [:email, :name, :password])   # alles andere wird verworfen
end

Auch die Eloquent Modelle von Laravel sind standardmäßig gegen Mass Assignment geschützt, über eine Liste $fillable oder $guarded am Modell.10 In reinem PHP mit einer handgeschriebenen Datenschicht ist der Schutz das, woran ihr Autor gedacht hat.

Wachsen ohne Schmerzen

Jede Anwendung, die Erfolg hat, muss mehr Traffic, mehr Daten und mehr Komplexität verkraften. Hier geben dir die Frameworks Werkzeuge, reines PHP gibt dir ein leeres Blatt.

Die N plus eins Abfrage

Der klassische Fehler bei der Performance: eine Liste laden und dann für jeden Eintrag einzeln die Datenbank abfragen.

# Eine Abfrage für die Vermieter, dann eine weitere pro Vermieter
Landlord.all.each { |landlord| puts landlord.tenancies.size }

# Zwei Abfragen insgesamt: die Vermieter, dann alle ihre Mietverhältnisse
Landlord.includes(:tenancies).each { |landlord| puts landlord.tenancies.size }

Achte auf size, nicht count. Auf einer Assoziation führt count immer eine Abfrage COUNT in der Datenbank aus, während size die schon geladenen Datensätze nutzt.11 Die ältere Fassung dieses Artikels hat in ihrem "korrigierten" Beispiel count verwendet und damit die zusätzlichen Abfragen still zurückgeholt. Rails kann das Nachladen auch ganz verweigern, mit strict_loading, dann löst der Fehler in der Entwicklung eine Ausnahme aus, statt die Produktion zu verlangsamen.12

Beispiel: der Abrechnungslauf bei Elbkontor

Elbkontor hat 1.000 Vermieter. Die erste Schleife schickt 1+1.000=1.0011 + 1{.}000 = 1{.}001 Abfragen. Die zweite schickt zwei. Dauert jeder Weg zur Datenbank 2 Millisekunden (eine Annahme für das Beispiel), verbringt die erste Schleife rund 1.001×2=2.0021{.}001 \times 2 = 2{.}002 Millisekunden, zwei Sekunden, nur mit Warten, die zweite rund 4 Millisekunden. Die Seite ist dieselbe. Das Warten ist 500 Mal kürzer.

Hintergrundjobs

Langsame Arbeit, etwa Mails verschicken oder eine Abrechnung erzeugen, gehört nicht in die Webanfrage. Rails hat Active Job eingebaut:

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)   # kehrt sofort zurück, der Job läuft im Hintergrund

Derselbe Jobcode läuft auf dem Warteschlangen Backend, das du konfigurierst. Laravel hat eigene Warteschlangen, was ich ihm im Vergleich der Frameworks auch zugutehalte.

Caching

Rails hat Fragment Caching in der View und die verschachtelte Form, die man Russian Doll Caching nennt:

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

Jeder Cache Eintrag trägt eine Version, die sich aus dem Datensatz ableitet, und wenn ein Datensatz aktualisiert wird, läuft sein Fragment ab. Ein Detail hat die ältere Fassung weggelassen: Damit das äußere Fragment abläuft, wenn sich ein inneres Element ändert, braucht das innere Modell belongs_to :landlord, touch: true, sodass die Aktualisierung eines Mietverhältnisses auch den Zeitstempel seines Vermieters aktualisiert.13 Mit dieser Zeile ist kein händisches Invalidieren nötig.

Datenbankmigrationen

Migrationen stellen das Datenbankschema unter Versionskontrolle:

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 spielt sie ein, bin/rails db:rollback nimmt sie zurück, und die Datenbank jedes Entwicklers bleibt im Gleichschritt. Ich habe PHP Projekte gesehen, in denen Schemaänderungen in einer Chatnachricht angekündigt und von Hand eingespielt wurden. Das ist ein Problem von reinem PHP, nicht von Laravel: Die Migrationen von Laravel erledigen dieselbe Aufgabe.14

Mit Tests wird die lange Strecke gewonnen

Das ist der Teil, der mir am wichtigsten ist, und hier unterscheiden sich die Voreinstellungen am stärksten.

Die Wirtschaftlichkeit, ohne Folklore

Oft liest du einen hübschen Multiplikator dafür, wie viel mehr ein Fehler kostet, je später er gefunden wird. Ich zitiere keinen, weil ich für keine bestimmte Zahl eine Quelle gefunden habe, hinter der ich stehen würde. An der Richtung gibt es aber keinen Zweifel. Ein Fehler, der beim Schreiben des Codes auffällt, kostet die Person, die schreibt, ein paar Minuten. Im Review kostet er zwei Leute. Finden ihn die Tester, muss er aufgeschrieben, nachgestellt, behoben und erneut getestet werden. Finden ihn die Kunden, kostet er auch ihr Vertrauen, und ein Fehler, durch den Daten abfließen, bringt rechtliche und aufsichtsrechtliche Kosten obendrauf.

Automatisierte Tests fangen Fehler auf der ersten Stufe, jedes Mal, wenn sich der Code ändert. Das ist das ganze Argument.

Test Driven Development

Test Driven Development dreht die übliche Reihenfolge um: den Test schreiben, ihn scheitern sehen, den wenigsten Code schreiben, mit dem er besteht, dann aufräumen. Rot, Grün, Refactoring.

# test/models/user_test.rb (zuerst geschrieben, scheitert also)
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 (der wenigste Code, mit dem er besteht)
class User < ApplicationRecord
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end

Was das bringt: Der Test formuliert die Absicht, bevor der Code existiert, der Code wird testbar, weil er es sein muss, und Refactoring ist sicher, weil dir die Tests sagen, was kaputt ist.

Behaviour Driven Development

BDD schreibt die wichtigen Wege als Szenarien auf, die eine Inhaberin ohne technischen Hintergrund lesen kann und die zugleich als Tests laufen. In Rails ist das Werkzeug Cucumber, und ich habe einen ganzen Artikel über Cucumber und BDD geschrieben:

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"

Fragt die Inhaberin, was passiert, wenn sich jemand mit einer ungültigen Mailadresse registriert, zeigst du ihr das Szenario. Es ist aktuell, weil es in jedem Build läuft.

RSpec, Factories und Systemtests

Viele Rails Teams nutzen RSpec wegen seiner lesbaren Struktur und factory_bot für Testdaten:

# 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@elbkontor.example")

Geld ist durchgehend ein BigDecimal, weil Gleitkommazahlen die meisten Centbeträge nicht exakt speichern können. Und Traits kommen in create vor den Attributen mit Schlüsselwort: Die ältere Fassung hatte create(:user, name: "VIP", :with_subscription), und das ist in Ruby ein Syntaxfehler.

Rails liefert außerdem Systemtests mit, die über Capybara einen echten Browser steuern:

# 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: "Große Elbstraße 14"
    fill_in "Town or city", with: "Hamburg"
    fill_in "Postcode", with: "22767"
    click_on "Pay and publish"

    assert_text "Your listing is live"
  end
end

Continuous Integration

Tests helfen nur, wenn sie laufen. Ein minimaler Workflow in GitHub Actions:

# .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

Die ältere Fassung hat RSpec mit continue-on-error: true laufen lassen, und damit besteht der Build auch mit scheiternder Suite. Nimm das raus. Und eine scheiternde Prüfung blockiert einen Merge nur, wenn der Branchschutz des Repositorys sie zu einer verpflichtenden Statusprüfung macht, also schalte auch das ein.15

Testen in PHP, fair betrachtet

Die ältere Fassung dieses Artikels hat behauptet, Tests in PHP hätten keine Factories, keinen Standardweg, Controller zu testen, keine Entsprechung zu Cucumber und keine Browsertests. Für reines PHP nur mit PHPUnit fühlt es sich ungefähr so an. Für das PHP Ökosystem insgesamt war es falsch, und ich korrigiere es hier:

  • Laravel liefert Model Factories für Testdaten mit.16
  • Laravel hat eine flüssige API für HTTP Tests gegen Routen und Controller, geschrieben in Pest oder PHPUnit.17
  • Laravel Dusk steuert einen Browser, und für Browsertests in neuen Projekten empfiehlt Laravel inzwischen Pest.18
  • Behat ist die PHP Umsetzung von Cucumber, für BDD in einfacher Sprache.19

Meine Sicht wird damit enger. In Rails sind Unit Tests, Systemtests, Fixtures, die Testdatenbank und die Generatoren eine einzige integrierte Voreinstellung, im Framework selbst, und die Kultur erwartet, dass du sie nutzt. In der PHP Welt gibt es dieselben Teile, meist rund um Laravel, aber ein Projekt in reinem PHP startet ohne sie, und wenn das Testen erst zusammengebaut werden muss, ist es das Erste, was "aus Zeitgründen" wegfällt.

Die lange Strecke, ein erfundener Vergleich

Zwei erfundene Projekte, gebaut für dasselbe Portal von Elbkontor, über drei Jahre begleitet. Es ist eine Geschichte, keine Datenerhebung, und sie vergleicht eine Kultur genauso wie eine Sprache: Ein Laravel Projekt mit derselben Testdisziplin sähe aus wie das zweite.

Projekt A, reines PHP ohne Tests. Der erste Monat ist schnell. Im dritten Monat bringen Fehlerbehebungen neue Fehler. Im sechsten Monat steht eine große Änderung an, und das Team traut sich nicht, etwas anzufassen. Im 12. Monat sind die beiden ursprünglichen Entwickler weg, und ihre Nachfolger brauchen Monate, um die Codebasis zu lernen. Im 18. Monat findet ein Audit Lücken für Injection und gefälschte Anfragen. Im 36. Monat schlägt jemand eine Neuentwicklung vor.

Projekt B, Rails mit Tests vom ersten Tag an. Der erste Monat fühlt sich langsamer an, weil Tests neben den Funktionen entstehen. Im dritten Monat fängt die Suite Rückschritte vor dem Release. Im sechsten Monat dauert die große Änderung eine Woche, weil die Tests zeigen, was sie berührt. Im 12. Monat lesen neue Entwickler die Tests, um die Funktionen zu lernen. Im 18. Monat stellt das Audit fest, dass die Voreinstellungen des Frameworks die üblichen Lücken abgedeckt haben. Im 36. Monat lässt sich die Codebasis immer noch ändern.

Beispiel: den Unterschied beziffern

Damit die Geschichte greifbar wird, hier erfundene Zahlen für die beiden Projekte von Elbkontor, alles Annahmen für das Beispiel. Beide kosten in der Entwicklung 60.000 €. Projekt B kostet im ersten Jahr wegen der Tests 20 % mehr, also 72.000 €. Danach bringt jedes Jahr Änderungswünsche für 30.000 € im Tempo von Projekt A. Projekt B liefert dieselben Änderungen mit 30 % weniger Aufwand, weil Änderungen vorhersagbar und sicher sind.

  • Projekt A über drei Jahre: 60.000+30.000+30.000=120.00060{.}000 + 30{.}000 + 30{.}000 = 120{.}000 €.
  • Projekt B über drei Jahre: 72.000+21.000+21.000=114.00072{.}000 + 21{.}000 + 21{.}000 = 114{.}000 €.

Unter diesen Annahmen kostet Projekt B über drei Jahre 6.000 € weniger, und der Abstand wächst mit jedem weiteren Jahr um 9.000 €. Setz statt 30 % nur 10 % an, und B wird teurer, 72.000+27.000+27.000=126.00072{.}000 + 27{.}000 + 27{.}000 = 126{.}000 €. Das ist die ehrliche Form des Arguments: Es hängt daran, wie viel schneller Änderungen mit Tests und Konventionen gehen, und genau diese Zahl solltest du für dein eigenes Projekt schätzen.

Was ich einem Kunden sagen würde

Bei einem neuen Projekt, das halten muss, empfehle ich Rails oder Django, weil sich die Konventionen, die sicheren Voreinstellungen und die Testkultur über die Jahre aufsummieren. Spricht das Team schon PHP, ist Laravel eine solide Wahl, und mir ist eine gut getestete Laravel Anwendung lieber als eine schlecht getestete in Rails. Was ich für nichts Langlebiges wählen würde, ist reines PHP ohne Framework, bei dem an jedes Sicherheitsnetz von Hand gedacht werden muss.

Rapid Development Frameworks haben ihren Namen nicht verdient, weil man mit ihnen schnell Code schreibt, sondern weil der Code schnell änderbar bleibt. Und wenn der Code von KI erzeugt wurde, ohne dass ihn jemand versteht, spielt das Framework weniger eine Rolle als die Tatsache, dass ihn niemand warten kann.

Supportzeiträume, die du vor der Wahl prüfen solltest

Was du auch wählst, plan die Upgrades ein. Stand meiner Prüfung im November 2026: Jeder PHP Zweig bekommt zwei Jahre aktiven Support und danach zwei weitere Jahre nur Sicherheitskorrekturen. PHP 8.2 bekommt Sicherheitskorrekturen bis zum 31. Dezember 2026, und der neueste Zweig, PHP 8.5, erschienen am 20. November 2025, bis zum 31. Dezember 2029.20 Laravel gibt jedem Major Release 18 Monate Fehlerkorrekturen und zwei Jahre Sicherheitskorrekturen. Laravel 13, erschienen am 17. März 2026, braucht PHP 8.3 bis 8.5 und hat Sicherheitskorrekturen bis zum 17. März 2028.21 Rails gibt jedem Minor Release zwei Jahre Sicherheitskorrekturen, und Rails 8.1 ist bis zum 10. Oktober 2027 abgedeckt.22 Django 5.2, das aktuelle Release mit Langzeitsupport, wird bis April 2028 unterstützt.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 Dokumentation, 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 Dokumentation, https://docs.djangoproject.com/en/stable/topics/security/

  6. Laravel, Blade templates, Laravel 13.x Dokumentation, 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 Dokumentation, 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 Dokumentation, laravel.com, https://laravel.com/docs/13.x/eloquent

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

  12. Ruby on Rails Guides, Active Record Query Interface, eager loading und 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 Dokumentation, 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 Dokumentation, laravel.com, https://laravel.com/docs/13.x/eloquent-factories

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

  18. Laravel, Laravel Dusk, Laravel 13.x Dokumentation, 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, abgerufen am 8. November 2026, https://www.php.net/supported-versions.php

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

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

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

Wenn du entscheidest, worauf du baust, oder mit einer PHP Codebasis lebst, die schwer änderbar geworden ist, bring sie in den kostenlosen Workshop mit. Wir sehen uns an, was es braucht, sie zu behalten, abzusichern oder umzuziehen, und die ersten zwei Wochen Arbeit gehen auf meine Rechnung.