Zum Inhalt springen
Abstrakte Formen aus Glas und dunklem Papier: eine verzweigte Baumstruktur aus Stäben, ein Weg von der Wurzel bis zum Ende leuchtet orange

PostgreSQL Indexing für Laravel-Apps: Wann welcher Index

B-Tree, GIN, GiST, Composite, Partial, Trigram - Postgres bietet ein halbes Dutzend Index-Typen. Wir zeigen anhand realer Laravel-Migrationen, wann welcher passt und wie Sie mit EXPLAIN ANALYZE den Effekt messen.

6 min Lesezeit Laravel & PHP

Vom Slow-Log zum Plan

Erst messen, dann indexieren

Ein Index, der eine Query nicht beschleunigt, kostet Sie trotzdem Write-Performance und Plattenplatz. Deshalb startet jede Indexierungs-Entscheidung bei uns mit einem konkreten Plan aus EXPLAIN ANALYZE - nie mit dem Bauchgefühl, dass „da bestimmt ein Index helfen würde“. Postgres ist beim Erzählen, was es tut, deutlich gesprächiger als MySQL.

Ein Messschieber misst die echte Größe des Werkstücks, statt der Schätzung zu trauen, der Schieber ist orange.

EXPLAIN ANALYZE lesen lernen

EXPLAIN ANALYZE führt die Query tatsächlich aus und liefert echte Zeiten pro Knoten. Wichtig sind drei Zahlen: actual time (die echte Laufzeit pro Knoten), rows (Schätzung vs. Realität) und loops. Wenn estimated und actual rows um Faktor 100 auseinanderliegen, hilft kein Index - dann fehlen Statistiken (ANALYZE) oder das Datenmodell ist schief.

Ein kleines Notizbuch mit Spiralbindung: bei so wenig Seiten ist Durchblättern schneller als jedes Register, das Gummiband ist orange.

Seq Scan ist nicht immer böse

Bei kleinen Tabellen oder Queries, die ohnehin den Großteil der Zeilen brauchen, ist ein Seq Scan schneller als ein Index Scan, weil der Planner sich die Indirektion über den Index-Heap spart. Erst wenn Postgres bei einer selektiven Query trotzdem sequenziell liest, lohnt sich ein neuer Index. Faustregel: Index, wenn weniger als 10 Prozent der Zeilen getroffen werden.

psql: EXPLAIN ANALYZE sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title FROM articles
WHERE author_id = 42 AND published_at IS NOT NULL
ORDER BY published_at DESC LIMIT 20;

-- Limit  (cost=0.42..18.74 rows=20 width=48) (actual time=0.041..0.182 rows=20 loops=1)
--   ->  Index Scan Backward using articles_author_published_idx on articles
--         (cost=0.42..1834.21 rows=2003 width=48)
--         (actual time=0.039..0.176 rows=20 loops=1)
--         Index Cond: (author_id = 42)
--         Filter: (published_at IS NOT NULL)
--   Buffers: shared hit=8
-- Planning Time: 0.182 ms
-- Execution Time: 0.214 ms

Index-Typen

B-Tree ist der Default - aber nicht die einzige Wahl

Postgres unterstützt B-Tree, Hash, GIN, GiST, SP-GiST, BRIN. Für 90 Prozent der Felder reicht B-Tree. Für JSONB, Volltext, Arrays und Geometrie gibt es spezialisierte Strukturen, die in der richtigen Situation um Größenordnungen schneller sind. Hier sind die drei, die Sie in einer Laravel-App regelmäßig brauchen.

Ein dickes Verzeichnis mit Daumenregister, sortiert von vorn bis hinten, der orange Griff schlägt den gesuchten Bereich direkt auf.

B-Tree für Gleichheit, Range und Sortierung

B-Tree ist der Standard hinter $table->index() in Laravel-Migrationen. Er deckt Vergleiche mit =, <, <=, >, >=, BETWEEN, IN und IS NULL ab und liefert sortierte Ergebnisse - perfekt, wenn Sie ORDER BY auf demselben Feld haben. Für Foreign Keys, Status-Spalten und Datumsfelder ist B-Tree fast immer richtig.

Ein Eierkarton: jedes Teil hat seinen eigenen Platz, das eine orange Ei ist sofort gefunden.

GIN für JSONB, Arrays und Volltext

GIN (Generalized Inverted Index) ist ein invertierter Index: Er indexiert die Tokens innerhalb eines Werts. Ideal für jsonb-Spalten mit ->> und @>-Operatoren, für Array-Mitgliedschaft (settings @> ARRAY['pro']) und für tsvector-Volltextsuche. Schreib-Overhead ist höher als bei B-Tree - bei Lese-lastigen Tabellen lohnt es trotzdem fast immer.

Ein Globus auf seinem Ständer für Geodaten und Überschneidungen, der Meridianring ist orange.

GiST für Geometrie und Range-Overlap

GiST (Generalized Search Tree) ist die Wahl für PostGIS-Geodaten, Range-Typen (tstzrange, int4range) und Nearest-Neighbour-Suchen. Klassisches Beispiel: Buchungssystem mit Überschneidungs-Check von Zeiträumen via && - ohne GiST-Index läuft das auf Seq Scan, mit Index in Millisekunden.

database/migrations/2026_05_23_000001_add_indexes_to_articles.php php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('articles', function (Blueprint $table) {
            // B-Tree (Default) auf einer Foreign-Key-Spalte
            $table->index('author_id', 'articles_author_idx');

            // GIN auf einer JSONB-Spalte für ->> und @> Operatoren
            // Laravel 13 unterstützt ->index() nicht für GIN - also raw:
            // (Spalte muss bereits als jsonb existieren)
        });

        DB::statement('CREATE INDEX articles_metadata_gin_idx
                       ON articles USING GIN (metadata jsonb_path_ops)');

        // Volltext über title + body, generierte tsvector-Spalte
        DB::statement("ALTER TABLE articles
                       ADD COLUMN search_vector tsvector
                       GENERATED ALWAYS AS (
                           to_tsvector('german', coalesce(title,'') || ' ' || coalesce(body,''))
                       ) STORED");
        DB::statement('CREATE INDEX articles_search_gin_idx
                       ON articles USING GIN (search_vector)');

        // GiST für Range-Overlap-Suche auf bookings
        DB::statement('CREATE INDEX bookings_period_gist_idx
                       ON bookings USING GIST (period)');
    }

    public function down(): void
    {
        DB::statement('DROP INDEX IF EXISTS articles_metadata_gin_idx');
        DB::statement('DROP INDEX IF EXISTS articles_search_gin_idx');
        DB::statement('DROP INDEX IF EXISTS bookings_period_gist_idx');
        DB::statement('ALTER TABLE articles DROP COLUMN IF EXISTS search_vector');

        Schema::table('articles', function (Blueprint $table) {
            $table->dropIndex('articles_author_idx');
        });
    }
};

Slow Queries im Live-System?

Wir lesen Ihren Slow-Log mit, schreiben die nötigen Indizes und liefern eine reversible Migration - inklusive Vorher/Nachher-Messung.

Performance-Audit anfragen

Composite, Partial, Trigram

Wo Standard-Indizes nicht reichen

Drei Patterns, die wir in jeder zweiten Codebase nachrüsten: Composite-Indizes mit korrekter Spaltenreihenfolge, Partial Indexes für sparse Werte und Trigram-Indexe, wenn das Frontend ILIKE %x% schickt. Alle drei sind in Postgres native - Sie brauchen kein Extra-Tooling.

Ein Zahlenschloss öffnet nur in der richtigen Reihenfolge der Ringe, der erste Ring ist orange.

Composite-Index: Reihenfolge ist alles

Ein Composite-Index auf (author_id, published_at DESC) bedient sowohl WHERE author_id = ? als auch WHERE author_id = ? ORDER BY published_at DESC LIMIT 20 - aber nicht WHERE published_at > ? alleine. Faustregel: gleichheitsgeprüfte Spalten zuerst, Range/Sortier-Spalten danach. Mit der richtigen Reihenfolge ersetzt ein einziger Composite zwei separate Indizes.

Ein Sieb behält nur, was zählt: die orangen Früchte bleiben, der Rest ist abgelaufen.

Partial Index: nur das, was zählt

Partial Indexes indexieren nur die Zeilen, die einer WHERE-Klausel entsprechen. Klassiker: nur veröffentlichte Artikel, nur offene Tickets, nur nicht-gelöschte Datensätze. Der Index wird kleiner, schneller und passt häufiger in den Buffer-Cache. Besonders wertvoll, wenn 95 Prozent Ihrer Zeilen einen Status haben, den Sie nie abfragen.

Ein Rechenschieber mit Perlen: drei orange Perlen gleiten als Gruppe zusammen, so wie drei Buchstaben einen Suchbaustein bilden.

Trigram-Index für LIKE und ILIKE

Wenn das Frontend ILIKE '%suche%' schickt - wofür ein B-Tree nutzlos ist - rettet Sie pg_trgm. Die Extension legt einen GIN- oder GiST-Index über Drei-Buchstaben-Gramme und macht damit Substring-Suche indizierbar. Aktivieren Sie in jedem Projekt mit Live-Search-Feldern auf Namen, Titeln oder Tags.

database/migrations/2026_05_23_000002_add_composite_partial_trigram_indexes.php php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        // Composite-Index: zuerst Gleichheit, dann Sortierung
        Schema::table('articles', function (Blueprint $table) {
            $table->index(
                ['author_id', 'published_at'],
                'articles_author_published_idx'
            );
        });

        // Partial Index: nur veröffentlichte Einträge
        DB::statement('CREATE INDEX articles_published_only_idx
                       ON articles (published_at DESC)
                       WHERE published_at IS NOT NULL');

        // Partial Index: nur offene Tickets
        DB::statement("CREATE INDEX tickets_open_idx
                       ON tickets (assigned_to, created_at)
                       WHERE status = 'open'");

        // Trigram für ILIKE-Suche auf customers.name
        DB::statement('CREATE EXTENSION IF NOT EXISTS pg_trgm');
        DB::statement('CREATE INDEX customers_name_trgm_idx
                       ON customers USING GIN (name gin_trgm_ops)');

        // Volltext-Fulltext direkt über Laravel-Schema (Postgres-Default)
        Schema::table('articles', function (Blueprint $table) {
            $table->fullText('title', 'articles_title_fulltext_idx');
        });
    }

    public function down(): void
    {
        DB::statement('DROP INDEX IF EXISTS articles_published_only_idx');
        DB::statement('DROP INDEX IF EXISTS tickets_open_idx');
        DB::statement('DROP INDEX IF EXISTS customers_name_trgm_idx');

        Schema::table('articles', function (Blueprint $table) {
            $table->dropFullText('articles_title_fulltext_idx');
            $table->dropIndex('articles_author_published_idx');
        });
    }
};

Hat das geholfen? Wir machen Audit, Workshop oder Coaching auch live

Pakete direkt im Shop. Unser Standard-Tagessatz liegt bei 1.500 € netto.

Performance-Audit anfragen