Skip to content
ALI HAITHAM·TECH·responds in <1h
  • Homestart here
  • Workcase studies, projects
  • Serviceswhat I will build for you
  • Writingessays, series
  • Traincourses, labs, cohorts
  • Aboutthe engineer behind this site
Sign inStart a project
ALI HAITHAM · TECH
  • Home↗
  • Work↗
  • Services↗
  • Writing↗
  • Train↗
  • About↗
Sign inStart a project
Online
ALI HAITHAM·TECH

Engineering studio. Damascus, GMT+3.

aliyosef.online

Studio

  • Work
  • Writing
  • Training
  • About
  • Contact me

Portal

  • Sign in
  • Open a ticket
  • Track project
  • My account

Resources

  • Docs
  • Status
  • Changelog
  • Brand kit
  • Privacy
  • Terms

Newsletter

Field notes and tech news. Weekly. No fluff.

Free. Unsubscribe anytime.

Find me elsewhere
© 2026 Ali Haitham Yosef. All rights reserved.Hand-built in React 19. No frameworks of frameworks.Last deployed · 2026-05-08All systems operational
  1. Home/
  2. Train/
  3. Labs/
  4. Add audit logs to a Laravel app
LAB02

Add audit logs to a Laravel app

Most audit-log implementations are fine until the table crosses ten million rows, then everything degrades at once. This lab walks through a Laravel-shaped audit log designed to stay queryable at 100M+ rows on commodity MySQL: append-only writes, monthly partitions, the right indexes, and a query layer that does not collapse the database when finance asks "who changed this invoice."

Start the lab↓Open the repo↗
min
45
STACK
Laravel
LEVEL
Intermediate
/02 — SETUP

What you will build

A minimal audit-log table that survives 100M rows. Append-only, partitioned, queryable.

Prerequisites

  1. 01Laravel 10 or 11 + MySQL 8 (or PostgreSQL 14+).
  2. 02You have run migrations before. You know what an index does.
  3. 03A test app with at least one model whose changes you would log.
/03 — WALKTHROUGH
/04 — RESOURCES

Take it with you.

  • ⌥
    RepositoryStarter code on GitHub.
  • ↓
    Zip downloadSame code, no git required.
/USED IN

Courses that pair with this lab.

  • Sync engines, end-to-end→
  • Multi-tenant Laravel→
/05 — NEXT
Try this next01Build a queue in 60 minutesAnother short, free lab.→
Or go deep

Multi-tenant Laravel

The full course extends this audit-log pattern to per-tenant partitioning, retention policies, and the broadcasting layer the same SaaS shipped in production.

See the course→

Why most audit-log tables die#

The standard "throw it in audit_logs and add an index" approach works until row 10 million, then writes start blocking reads, the index fragments, and the support team's "who changed this invoice last Tuesday" query takes 40 seconds. The fix is not "add more indexes." The fix is to design the table for the access pattern from day one: append-only, partitioned by month, with two indexes that match the only two queries you will ever run.

Step 1: the table#

CREATE TABLE audit_logs (
  id           BIGINT UNSIGNED AUTO_INCREMENT,
  occurred_at  DATETIME(6) NOT NULL,
  actor_id     BIGINT UNSIGNED NULL,
  actor_type   VARCHAR(60) NOT NULL,
  entity_id    BIGINT UNSIGNED NOT NULL,
  entity_type  VARCHAR(60) NOT NULL,
  action       VARCHAR(40) NOT NULL,
  payload      JSON NULL,
  PRIMARY KEY (id, occurred_at),
  KEY idx_entity (entity_type, entity_id, occurred_at),
  KEY idx_actor  (actor_type, actor_id, occurred_at)
)
ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(occurred_at)) (
  PARTITION p_2026_01 VALUES LESS THAN (TO_DAYS('2026-02-01')),
  PARTITION p_2026_02 VALUES LESS THAN (TO_DAYS('2026-03-01')),
  PARTITION p_max     VALUES LESS THAN MAXVALUE
);

Three things that look small but are not:

  1. The primary key is composite. (id, occurred_at). MySQL requires every partitioning column to be in every unique key. Drop occurred_at from the PK and partitioning fails on ALTER.
  2. The two indexes are (entity_type, entity_id, occurred_at) and (actor_type, actor_id, occurred_at). These match the only two real queries: "show me the history of this entity" and "show me what this user did." Any other index is speculative bloat.
  3. The payload is JSON, not a normalised set of field_name/old_value/new_value rows. Auditing is not your data warehouse. You write once and read rarely; flat denormalised JSON is correct here.

Step 2: the Laravel migration#

public function up(): void
{
    DB::statement(<<<'SQL'
        CREATE TABLE audit_logs (
            id           BIGINT UNSIGNED AUTO_INCREMENT,
            occurred_at  DATETIME(6) NOT NULL,
            actor_id     BIGINT UNSIGNED NULL,
            actor_type   VARCHAR(60) NOT NULL,
            entity_id    BIGINT UNSIGNED NOT NULL,
            entity_type  VARCHAR(60) NOT NULL,
            action       VARCHAR(40) NOT NULL,
            payload      JSON NULL,
            PRIMARY KEY (id, occurred_at),
            KEY idx_entity (entity_type, entity_id, occurred_at),
            KEY idx_actor  (actor_type, actor_id, occurred_at)
        )
        ENGINE=InnoDB
        PARTITION BY RANGE (TO_DAYS(occurred_at)) (
            PARTITION p_max VALUES LESS THAN MAXVALUE
        )
    SQL);
}

We start with a single p_max partition and add monthly partitions via a scheduled command (next step). Trying to define every partition upfront is the wrong shape: you would need a migration every month forever.

Step 3: the partition rotator#

A scheduled job runs on the first of every month and adds the next partition before any rows can land in p_max. Below is a compact version that lives in app/Console/Commands/RotateAuditPartitions.php:

public function handle(): int
{
    $next = now()->addMonth()->startOfMonth();
    $name = 'p_' . $next->format('Y_m');
    $boundary = $next->copy()->addMonth()->format('Y-m-d');

    DB::statement("ALTER TABLE audit_logs REORGANIZE PARTITION p_max INTO (
        PARTITION {$name} VALUES LESS THAN (TO_DAYS('{$boundary}')),
        PARTITION p_max   VALUES LESS THAN MAXVALUE
    )");

    return self::SUCCESS;
}

Schedule it in app/Console/Kernel.php:

$schedule->command('audit:rotate-partitions')->monthlyOn(1, '02:00');

Two early-morning failure modes are worth knowing. First, REORGANIZE is blocking on MySQL 5.x; on 8.0 with ALGORITHM=INPLACE it is not, but it locks for metadata briefly. Schedule it in your low-traffic window. Second, if you skip a month (job failure, server down), the next run is fine: it rebuilds p_max from whatever boundary it is at.

Step 4: the writer#

The writer is a single Eloquent observer plus a queue job. The observer captures the change atomically; the queue job persists it. Splitting them keeps the request path fast.

class AuditedObserver
{
    public function updated(Model $model): void
    {
        if (! $model->isDirty()) return;

        AuditLog::dispatch(
            actorId:    auth()->id(),
            actorType:  auth()->user() ? 'user' : 'system',
            entityId:   $model->getKey(),
            entityType: $model->getMorphClass(),
            action:     'updated',
            payload:    $model->getDirty(),
        );
    }
}

The queue job:

class AuditLog implements ShouldQueue
{
    public function handle(): void
    {
        DB::table('audit_logs')->insert([
            'occurred_at' => now()->format('Y-m-d H:i:s.u'),
            'actor_id'    => $this->actorId,
            'actor_type'  => $this->actorType,
            'entity_id'   => $this->entityId,
            'entity_type' => $this->entityType,
            'action'      => $this->action,
            'payload'     => json_encode($this->payload),
        ]);
    }
}

Notice the DATETIME(6) precision in the timestamp. Microseconds matter when two writes land in the same millisecond and you need a deterministic ordering for the entity-history view.

Step 5: the only two queries you will ever run#

// Entity history
AuditLog::query()
    ->where('entity_type', 'invoice')
    ->where('entity_id', $invoiceId)
    ->orderByDesc('occurred_at')
    ->limit(50)
    ->get();

// Actor activity
AuditLog::query()
    ->where('actor_type', 'user')
    ->where('actor_id', $userId)
    ->orderByDesc('occurred_at')
    ->limit(50)
    ->get();

Both should return in single-digit milliseconds at any scale. If they do not, the index is wrong, not the partitioning. EXPLAIN the query and confirm Using index appears in the Extra column.

What this lab is not#

This is the table and the writer. It is not a retention policy (deleting partitions older than X), not a per-tenant scoping (every row carries an implicit tenant_id in production), and not a broadcast layer (so the admin UI updates live when an audit row lands). The full Multi-tenant Laravel course covers all three on top of this exact table.