Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/HypathStack/model-scribe/llms.txt

Use this file to discover all available pages before exploring further.

ModelScribe sits between your Eloquent models and your persistence layer. When a model changes, an observer intercepts the lifecycle event, assembles an immutable LogEntry value object, and hands it off to the DriverManager, which routes it to whatever backend you have configured — a database table, a log file, or both at once via the stack driver. The only thing you need to do is add the HasAuditLog trait to a model; everything else is wired up automatically.

The Audit Pipeline

1

Eloquent fires a lifecycle event

Laravel’s Eloquent ORM dispatches events like created, updated, deleted, and restored any time a model’s state changes. ModelScribe listens for all four.
2

ModelScribeObserver intercepts the event

Every model using the HasAuditLog trait is registered with ModelScribeObserver at boot time. The observer’s corresponding method (created(), updated(), etc.) calls the internal handle() method, which first verifies the event is in the model’s $auditEvents list before proceeding.
3

Observer builds the LogEntry DTO

handle() calls buildProperties() to produce the before/after diff, then calls resolveCauser() to identify the authenticated user. It combines these with request context (URL, IP, User Agent) and model metadata (log name, tags, driver preference) into a new LogEntry instance.
4

Log name is resolved

resolveLogName() determines the final routing key. If the model has a custom $auditLogName set, that value is used directly. If the log name is still default, the observer checks guard_stores in the config and uses the first matching guard’s mapped log name.
5

DriverManager resolves the driver

The observer calls $this->manager->driver($driverName), where $driverName comes from the model’s $auditDriver property (or the global default driver from config). The DriverManager resolves the driver once and caches it for the lifetime of the request.
6

Driver persists the entry

The resolved driver’s log(LogEntry $entry) method is called. The database driver inserts a row (or multiple rows, for multi-table stores). The file driver writes a structured info-level log line. The stack driver fans the entry out to each of its listed sub-drivers in sequence.

Observer Registration via bootHasAuditLog()

When Laravel boots an Eloquent model, it automatically calls any static boot{TraitName}() method defined in a trait. HasAuditLog uses this hook to register the observer with zero boilerplate:
public static function bootHasAuditLog(): void
{
    static::observe(ModelScribeObserver::class);
}
This means the moment you add use HasAuditLog; to a model, the observer is active — no service provider registration, no manual ::observe() call needed.

How buildProperties() Constructs the Diff

The properties array on every LogEntry always contains two keys: old and attributes. What goes into each key depends on the event type.

created and restored

Both events represent a model coming into existence (or back into existence). There is no meaningful “before” state, so old is always an empty array. All current attributes — or the subset defined in $auditAttributes for the event — are captured under attributes.
// Result shape
['old' => [], 'attributes' => ['status' => 'pending', 'total' => 100]]

updated

Only the attributes that actually changed are recorded. The observer calls $model->getDirty() to find changed keys, then fetches the pre-change values via $model->getOriginal($key). If $auditAttributes limits the tracked fields for the updated event, any dirty key not in that list is silently skipped.
// Result shape — only the changed fields appear
['old' => ['status' => 'pending'], 'attributes' => ['status' => 'shipped']]

deleted

A deletion has no meaningful “after” state. The current attribute snapshot is placed in old, and attributes is an empty array. This mirrors the created shape in reverse, making it easy to identify tombstone records at a glance.
// Result shape
['old' => ['status' => 'shipped', 'total' => 100], 'attributes' => []]

How resolveLogName() Picks a Routing Key

The final log name written to the LogEntry is determined by this two-step fallback:
  1. Model-defined name wins. If $model->getAuditLogName() returns anything other than 'default', that value is used as-is. This is the cleanest way to route a specific model to its own table or log store.
  2. Guard-store mapping as fallback. If the name is still 'default', the observer iterates over the guard_stores array in config/model-scribe.php. The first guard that has an authenticated user becomes the source of the mapped log name. This lets you automatically route API-authenticated writes to a different store than web-authenticated ones — without touching the model.
// config/model-scribe.php
'guard_stores' => [
    'api' => 'api_audit',
    'web' => 'web_audit',
],

How resolveCauser() Identifies the Actor

The observer resolves the causer by checking the auth_guard setting in config/model-scribe.php:
  • If auth_guard is null (the default), it calls Auth::user() and uses whichever guard is currently active.
  • If auth_guard names a specific guard (e.g., 'api'), it calls Auth::guard('api')->user() regardless of the default guard.
The resolved user must be an Eloquent Model instance to be stored; any non-model value (such as a custom user object) is treated as null.
// config/model-scribe.php
'auth_guard' => 'api', // Always resolve causer from the API guard
When capture_request_context is true in your config (the default), the observer automatically attaches the full request URL (Request::fullUrl()), the client IP address (Request::ip()), and the User Agent string (Request::userAgent()) to every LogEntry. Set capture_request_context to false to suppress this for queue workers or console commands where HTTP context is unavailable.

Build docs developers (and LLMs) love