Audit Eloquent minimal avec Observers et JSON
Implémente un audit léger des modèles Eloquent sans package: enregistre les changements (avant/après), l’utilisateur et le contexte via un Observer générique. Tuto concret, prêt à copier-coller.
Sommaire
Audit Eloquent minimal avec Observers et JSON
Ce tutoriel montre comment auditer proprement vos modèles Eloquent sans dépendance externe. L’approche repose sur un Observer générique, une table audit_logs et une relation morphique, pour consigner les changements (avant/après), l’utilisateur et le contexte de requête. Le tout est copiable-collable, performant et facile à étendre.
Objectif
L’objectif est de mettre en place un audit léger et fiable des opérations sur vos modèles Eloquent. À chaque création, mise à jour, suppression, restauration et suppression forcée, nous allons enregistrer l’événement, les deltas au format JSON (before/after), l’utilisateur à l’origine de l’action, l’adresse IP et le user agent. Nous suivrons une implémentation pragmatique qui n’ajoute pas de lourdeur à votre code métier, et qui peut être activée modèle par modèle.
Objectif et périmètre
Nous allons créer une table audit_logs et un modèle AuditLog pour tracer les événements created, updated, deleted, restored, forceDeleted. Chaque log sera lié au modèle cible via une relation morphique (auditable_type et auditable_id), et enrichi avec user_id, ip et user_agent pour le contexte. Pour chaque modification, seuls les attributs modifiés seront enregistrés, en séparant ce qui existait avant (before) et ce qui a été enregistré après (after). Une configuration centralisée permettra de masquer des attributs sensibles comme les mots de passe ou des tokens, avec la possibilité d’un masquage spécifique par modèle. Un Observer générique réalisera le travail et sera activé via un trait Auditable, afin que l’audit ne s’applique qu’aux modèles que vous décidez.
Migration de la table audit_logs
Commencez par générer une migration dédiée à la table d’audit. Cette table contiendra l’identification morphique du modèle audité, l’utilisateur, le type d’événement, les deltas JSON et le contexte réseau.
php artisan make:migration create_audit_logs_table
Éditez ensuite la migration pour créer la table. L’exemple ci-dessous fonctionne tel quel sur MySQL et PostgreSQL. Sur MySQL, vérifiez que votre base est en utf8mb4 pour stocker correctement tout le contenu JSON, et utilisez une colonne ip de longueur 45 pour IPv6. Si vous êtes sur PostgreSQL et souhaitez indexer des documents JSON, vous pouvez substituer json par jsonb.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
// Relation morphique vers le modèle audité
$table->morphs('auditable'); // auditable_type, auditable_id
// Contexte utilisateur (nullable pour les tâches console/queues)
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
// Type d'événement: created, updated, deleted, restored, forceDeleted
$table->string('event', 20);
// Deltas (avant/après) au format JSON
$table->json('before')->nullable();
$table->json('after')->nullable();
// Contexte de requête
$table->string('ip', 45)->nullable(); // IPv4/IPv6
$table->string('user_agent', 255)->nullable();
$table->timestamps();
// Index utiles pour recherche et purge
$table->index(['event']);
$table->index(['created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};
Appliquez la migration:
php artisan migrate
Si vous ciblez PostgreSQL et voulez profiter d’index GIN, remplacez simplement $table->json(...) par $table->jsonb(...) et ajoutez des index GIN selon vos requêtes ultérieures.
Modèle AuditLog et relations
Créez le modèle Eloquent correspondant. Il exposera les relations vers le modèle audité (morphTo) et vers l’utilisateur, castant before et after en tableaux pour une manipulation simple.
php artisan make:model AuditLog
Modifiez app/Models/AuditLog.php comme suit:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
protected $guarded = [];
protected $casts = [
'before' => 'array',
'after' => 'array',
];
public function auditable()
{
return $this->morphTo();
}
public function user()
{
return $this->belongsTo(\App\Models\User::class);
}
// Filtre pratique pour retrouver les logs d'un modèle précis
public function scopeForModel(Builder $query, Model $model): Builder
{
return $query->where('auditable_type', $model->getMorphClass())
->where('auditable_id', $model->getKey());
}
}
Dans vos tests, vous pouvez aussi ajouter une factory à AuditLog si vous devez en générer, même si dans ce tutoriel les logs sont créés par l’Observer.
Observer générique AuditableObserver
L’Observer va écouter les événements Eloquent et consigner les deltas. Il gère les événements created, updated, deleted, restored et forceDeleted. Il filtre les champs sensibles via la config, détecte l’utilisateur courant et capture le contexte de la requête lorsqu’il est disponible.
Générez l’Observer:
php artisan make:observer AuditableObserver
Implémentez app/Observers/AuditableObserver.php:
<?php
namespace App\Observers;
use App\Models\AuditLog;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class AuditableObserver
{
public function created(Model $model): void
{
$this->record('created', $model);
}
public function updated(Model $model): void
{
$this->record('updated', $model);
}
public function deleted(Model $model): void
{
$this->record('deleted', $model);
}
public function restored(Model $model): void
{
$this->record('restored', $model);
}
public function forceDeleted(Model $model): void
{
$this->record('forceDeleted', $model);
}
protected function record(string $event, Model $model): void
{
// Détermination des clés modifiées selon l'événement
// - created: tout ce qui est présent après la sauvegarde
// - updated: changements de la dernière sauvegarde
// - deleted/forceDeleted: on journalise l'état complet "avant"
// - restored: on se concentre sur deleted_at si SoftDeletes, sinon journalisation minimale
$excluded = $this->excludedKeys($model);
$before = [];
$after = [];
if ($event === 'created') {
$attrs = Arr::except($model->getAttributes(), $excluded);
// Ne pas stocker des blobs volumineux inutilement si vous en avez (adapter si besoin)
$after = $attrs;
} elseif ($event === 'updated') {
// getChanges contient les colonnes modifiées lors de la dernière opération persistée
$changes = Arr::except($model->getChanges(), $excluded);
// Si rien n’a été modifié (peut arriver sur certains setAttribute), on ne log pas
if (empty($changes)) {
return;
}
$before = Arr::only($model->getOriginal(), array_keys($changes));
$before = Arr::except($before, $excluded);
$after = Arr::only($model->getAttributes(), array_keys($changes));
$after = Arr::except($after, $excluded);
} elseif (in_array($event, ['deleted', 'forceDeleted'], true)) {
$before = Arr::except($model->getOriginal(), $excluded);
$after = [];
} elseif ($event === 'restored') {
// Cas le plus fréquent: SoftDeletes, on journalise deleted_at
$hadSoftDeletes = in_array(SoftDeletes::class, class_uses_recursive($model));
if ($hadSoftDeletes) {
$deletedAtKey = defined(get_class($model) . '::DELETED_AT') ? $model::DELETED_AT : 'deleted_at';
$beforeVal = $model->getOriginal($deletedAtKey);
$before = $beforeVal ? [$deletedAtKey => $beforeVal] : [];
$after = [$deletedAtKey => $model->getAttribute($deletedAtKey)];
$before = Arr::except($before, $excluded);
$after = Arr::except($after, $excluded);
} else {
// Restauration sans SoftDeletes: journalisation minimale
$before = [];
$after = [];
}
}
// Si after et before sont vides pour un updated/created, on peut éviter d'insérer
if (in_array($event, ['created', 'updated'], true) && empty($before) && empty($after)) {
return;
}
$userId = auth()->id();
$ip = null;
$ua = null;
// En contexte HTTP, certaines actions peuvent être en queue/CLI
if (app()->bound('request')) {
$request = request();
$ip = optional($request)->ip();
$ua = optional($request)->userAgent();
}
// Insertion du log
AuditLog::create([
'auditable_type' => $model->getMorphClass(), // respecte le morph map s’il existe
'auditable_id' => $model->getKey(),
'user_id' => $userId,
'event' => $event,
'before' => !empty($before) ? $before : null,
'after' => !empty($after) ? $after : null,
'ip' => $ip,
'user_agent' => $ua,
]);
}
protected function excludedKeys(Model $model): array
{
$global = (array) config('audit.except', []);
$perModel = (array) data_get(config('audit.models', []), $model::class . '.except', []);
// Vous pouvez décider d’exclure updated_at par défaut si vous ne souhaitez pas le voir dans les deltas
$defaults = [];
return array_values(array_unique(array_merge($global, $perModel, $defaults)));
}
}
Deux remarques pratiques:
- Sur de gros modèles, évitez d’appeler toArray() qui peut hydrater des relations; ne manipulez que getAttributes(), getOriginal() et getChanges().
- Si vous avez des colonnes volumineuses (texte long, JSON massifs), réfléchissez au masquage via la config pour ne logguer que l’essentiel.
Trait Auditable et enregistrement de l’Observer
Le trait rend l’audit opt-in par modèle. Il enregistre l’Observer à la boot du modèle; vous l’activez par un simple use Auditable.
Créez le trait:
php artisan make:trait Auditable
Implémentez app/Models/Concerns/Auditable.php:
<?php
namespace App\Models\Concerns;
use App\Observers\AuditableObserver;
trait Auditable
{
public static function bootAuditable(): void
{
static::observe(AuditableObserver::class);
}
}
Activez l’audit sur les modèles voulus. Exemple avec un modèle Post:
<?php
namespace App\Models;
use App\Models\Concerns\Auditable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use Auditable;
use SoftDeletes;
protected $fillable = ['title', 'body', 'password']; // 'password' ici pour démontrer le masquage
}
Alternative avancée: si vous ne souhaitez pas ajouter le trait sur chaque modèle, vous pouvez enregistrer l’Observer dans un service provider en lisant une liste de classes depuis la config:
// App\Providers\AppServiceProvider.php
use App\Observers\AuditableObserver;
public function boot(): void
{
foreach (array_keys(config('audit.models', [])) as $class) {
if (class_exists($class)) {
$class::observe(AuditableObserver::class);
}
}
}
Configuration et masquage des attributs
Centralisez le masquage dans un fichier config/audit.php. Commencez par créer ce fichier avec des exclusions globales et, si besoin, des exclusions par modèle. Vous pourrez y ajouter vos classes et champs sensibles.
<?php
// config/audit.php
return [
// Attributs exclus pour tous les modèles
'except' => [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
],
// Surcharges spécifiques par modèle
'models' => [
\App\Models\User::class => [
'except' => [
'email_verified_at',
'password',
],
],
// \App\Models\Post::class => [
// 'except' => ['password'],
// ],
],
];
Au moment d’écrire le log, l’Observer fusionne les exclusions globales et spécifiques au modèle. Si vous stockez des données particulièrement sensibles dans before/after, vous pouvez tirer parti des casts chiffrés de Laravel et chiffrer le JSON de manière transparente:
// Dans App\Models\AuditLog
protected $casts = [
// Remplacez par 'encrypted:array' si vous souhaitez chiffrer
// 'before' => 'encrypted:array',
// 'after' => 'encrypted:array',
'before' => 'array',
'after' => 'array',
];
Cette approche permet d’éviter toute exposition accidentelle si votre base est compromise, au prix d’un coût de chiffrement/déchiffrement.
Affichage minimal des logs (liste par modèle)
Pour consulter rapidement les logs d’un enregistrement particulier, exposez une route d’administration qui liste les événements pour un couple (type, id). Cette route ne s’occupe que d’afficher, l’accès étant protégé.
Déclarez la route:
<?php
// routes/web.php
use App\Http\Controllers\AuditLogController;
use Illuminate\Support\Facades\Route;
Route::get('/admin/audit/{type}/{id}', AuditLogController::class)
->middleware(['auth'])
->name('admin.audit.show');
Créez le contrôleur invocable:
php artisan make:controller AuditLogController --invokable
Implémentez app/Http/Controllers/AuditLogController.php:
<?php
namespace App\Http\Controllers;
use App\Models\AuditLog;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;
class AuditLogController extends Controller
{
public function __invoke(Request $request, string $type, string $id)
{
// Résout {type} via le morph map si présent, sinon on accepte un FQCN encodé
$resolved = Relation::getMorphedModel($type) ?: urldecode($type);
abort_unless(class_exists($resolved), 404);
// Facultatif: autorisation fine (Policy / Gate)
$this->authorize('viewAny', AuditLog::class);
$logs = AuditLog::query()
->where('auditable_type', (new $resolved())->getMorphClass())
->where('auditable_id', $id)
->with('user')
->latest()
->paginate(50);
return view('admin.audit.show', [
'logs' => $logs,
'type' => $type,
'id' => $id,
]);
}
}
Créez une vue minimaliste resources/views/admin/audit/show.blade.php pour lister et parcourir les deltas. L’exemple ci-dessous tronque les valeurs longues pour préserver la lisibilité.
{{-- resources/views/admin/audit/show.blade.php --}}
@php
use Illuminate\Support\Str;
@endphp
@extends('layouts.app')
@section('content')
<h1>Audit {{ $type }} #{{ $id }}</h1>
@foreach ($logs as $log)
<div style="border:1px solid #e5e7eb; padding:12px; margin-bottom:10px;">
<div>
<strong>Événement:</strong> {{ $log->event }}
<strong>Par:</strong> {{ optional($log->user)->email ?? 'Système' }}
<strong>Quand:</strong> {{ $log->created_at->diffForHumans() }}
</div>
<div>
<small>IP: {{ $log->ip ?? 'N/A' }} — UA: {{ Str::limit($log->user_agent ?? 'N/A', 80) }}</small>
</div>
@php
$keys = collect(array_keys(($log->before ?? []) + ($log->after ?? [])))->unique()->values();
@endphp
@if ($keys->isNotEmpty())
<ul>
@foreach ($keys as $key)
@php
$b = $log->before[$key] ?? null;
$a = $log->after[$key] ?? null;
$bStr = is_scalar($b) ? (string)$b : json_encode($b, JSON_UNESCAPED_UNICODE);
$aStr = is_scalar($a) ? (string)$a : json_encode($a, JSON_UNESCAPED_UNICODE);
@endphp
<li>
<code>{{ $key }}</code>:
<em>{{ Str::limit($bStr, 120) }}</em>
=>
<strong>{{ Str::limit($aStr, 120) }}</strong>
</li>
@endforeach
</ul>
@else
<em>Aucun delta capturé pour cet événement.</em>
@endif
</div>
@endforeach
{{ $logs->links() }}
@endsection
Protégez l’accès à la route en ajoutant une policy ou une Gate selon vos rôles internes, afin de restreindre la visibilité des logs aux administrateurs ou aux équipes de support.
Tests rapides (Pest ou PHPUnit)
Vérifiez le bon fonctionnement par quelques tests fonctionnels. L’exemple suivant utilise Pest et un modèle Post audité, avec un utilisateur connecté. Il couvre la création, la mise à jour avec vérification des deltas, le masquage d’attributs sensibles et les événements de suppression/restauration.
Modèle Post pour les tests (si vous ne l’avez pas déjà):
<?php
// app/Models/Post.php
namespace App\Models;
use App\Models\Concerns\Auditable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use Auditable, SoftDeletes;
protected $fillable = ['title', 'body', 'password'];
}
Migration simplifiée pour Post (si nécessaire):
php artisan make:migration create_posts_table
<?php
// database/migrations/xxxx_xx_xx_xxxxxx_create_posts_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body')->nullable();
$table->string('password')->nullable();
$table->softDeletes();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
Exemples de tests Pest:
<?php
// tests/Feature/AuditTest.php
use App\Models\AuditLog;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('audit.except', ['password', 'remember_token']);
});
it('loggue un événement created', function () {
$user = User::factory()->create();
$this->actingAs($user);
$post = Post::create(['title' => 'Hello', 'body' => 'World']);
$this->assertDatabaseHas('audit_logs', [
'event' => 'created',
'auditable_type' => $post->getMorphClass(),
'auditable_id' => $post->id,
'user_id' => $user->id,
]);
$log = AuditLog::query()->latest()->first();
expect($log->after['title'])->toBe('Hello');
});
it('loggue les deltas sur updated', function () {
$user = User::factory()->create();
$this->actingAs($user);
$post = Post::create(['title' => 'Ancien', 'body' => 'Texte']);
$post->update(['title' => 'Nouveau']);
$log = AuditLog::query()->where('event', 'updated')->latest()->first();
expect($log->before['title'])->toBe('Ancien');
expect($log->after['title'])->toBe('Nouveau');
expect($log->after)->not->toHaveKey('password');
});
it('masque les attributs sensibles', function () {
$user = User::factory()->create();
$this->actingAs($user);
$post = Post::create(['title' => 'T', 'body' => 'B', 'password' => 'secret']);
$post->update(['password' => 'nouveau-secret']);
$log = AuditLog::query()->where('event', 'updated')->latest()->first();
expect($log->before)->not->toHaveKey('password');
expect($log->after)->not->toHaveKey('password');
});
it('loggue deleted puis restored', function () {
$user = User::factory()->create();
$this->actingAs($user);
$post = Post::create(['title' => 'À supprimer']);
$post->delete();
$deletedLog = AuditLog::query()->where('event', 'deleted')->latest()->first();
expect($deletedLog)->not->toBeNull();
$post->restore();
$restoredLog = AuditLog::query()->where('event', 'restored')->latest()->first();
expect($restoredLog)->not->toBeNull();
});
Ces tests utilisent RefreshDatabase pour repartir d’une base propre et valident l’ensemble du flux: écriture des logs, cohérence des before/after et masquage.
Aller plus loin
Il est possible d’alléger l’impact perçu côté utilisateur en écrivant les logs après la réponse HTTP. Pour cela, déclenchez l’écriture via un job que vous expédiez avec dispatchAfterResponse. En pratique, vous pouvez soit transformer l’insertion en job asynchrone, soit pousser une file à faible priorité, selon vos contraintes.
Le nettoyage régulier de la table se réalise proprement avec le trait Prunable. Ajoutez-le à votre modèle AuditLog et définissez une politique de purge, par exemple après 90 jours. Planifiez ensuite la purge quotidienne via le scheduler de Laravel.
Exemple succinct de purge:
<?php
// App\Models\AuditLog.php
use Illuminate\Database\Eloquent\Prunable;
class AuditLog extends Model
{
use Prunable;
public function prunable()
{
return static::where('created_at', '<', now()->subDays(90));
}
}
Et dans app/Console/Kernel.php:
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{
$schedule->command('model:prune', [
'--model' => [\App\Models\AuditLog::class],
])->daily();
}
Pour PostgreSQL, si vous avez besoin d’interroger before/after, profitez de jsonb et des index GIN, voire jsonb_path_ops selon vos requêtes. Une migration d’index pourrait ressembler à:
Schema::table('audit_logs', function (Blueprint $table) {
$table->index(['after'], 'audit_after_gin_index')->algorithm('gin'); // via raw si nécessaire
});
En contexte multi-tenant, ajoutez une colonne tenant_id à audit_logs et remplissez-la depuis votre contexte (scope global, middleware tenant, etc.). Vous pourrez ensuite partitionner ou filtrer les logs par tenant.
Enfin, pour les imports massifs, groupez les insertions pour réduire la charge d’I/O. Par exemple, collectez plusieurs événements et utilisez insert() en une passe si votre logique s’y prête, ou basculez l’écriture en queue.
Checklist
Avant de valider en production, prenez le temps de relire l’Observer et la configuration pour confirmer que toutes les colonnes sensibles sont bien exclues et que les deltas répondent à vos attentes. Exécutez les commandes Artisan proposées et copiez les snippets dans les bons emplacements pour vérifier que tout compile et migre correctement. Lancez ensuite les tests proposés (ou vos propres tests end-to-end) afin d’attraper d’éventuels cas limites sur vos modèles réels. Une fois les résultats validés, publiez votre code et surveillez la volumétrie de la table audit_logs durant les premiers jours afin d’ajuster la stratégie de rétention.
Conclusion
Vous disposez désormais d’un audit Eloquent simple, transparent et extensible, sans package tiers. Grâce à un Observer générique, une config centralisée et une relation morphique, vous capturez les changements utiles avec un minimum d’overhead. Vous pouvez l’activer modèle par modèle via un trait, affiner le masquage d’attributs et brancher un affichage d’administration minimal. Les pistes d’amélioration proposées vous permettront d’adapter la solution à votre contexte (asynchrone, multi-tenant, rétention, indexation).
Ressources
Pour approfondir ou adapter certains points, consultez les documents suivants:
- Observers Eloquent: https://laravel.com/docs/eloquent#observers
- Relations morphiques: https://laravel.com/docs/eloquent-relationships#polymorphic-relationships
- Casts et casts chiffrés: https://laravel.com/docs/eloquent-mutators#array-and-json-casting et https://laravel.com/docs/encryption#encrypted-casting
- Authentification: https://laravel.com/docs/authentication
- Requêtes HTTP: https://laravel.com/docs/requests
- Prunable et purge de modèles: https://laravel.com/docs/eloquent#pruning-models
- Planificateur de tâches: https://laravel.com/docs/scheduling
- PostgreSQL JSONB et index GIN: https://www.postgresql.org/docs/current/datatype-json.html et https://www.postgresql.org/docs/current/gin-intro.html