locastic/loggastic
Loggastic is a Laravel package for logging user activity and model changes. It records who did what and when, helping you build audit trails and simplify debugging. Lightweight and easy to drop into existing Laravel applications.
Install and wire it up
composer require locastic/loggastic
Flex will register the bundle; on older Symfony versions add Locastic\Loggastic\LoggasticBundle::class => ['all' => true] to config/bundles.php. Note that doctrine/dbal (^3.8 || ^4.0) is now an explicit dependency — if you had dbal pinned below 3.8 anywhere, resolve that first.
Pick your storage before you pick your infra. As of 2.1.0 the backend is one config key:
# config/packages/loggastic.yaml
locastic_loggastic:
storage: doctrine # 'elasticsearch' (default), 'doctrine' or 'in_memory'
The default is still elasticsearch, so nothing changes on upgrade — but you no longer need a cluster to get started. If you just want to evaluate the package, set storage: in_memory and skip ELASTICSEARCH_URL entirely; no ES client, no elastic_* config, no PSR-18 requirement gets registered. Only configure a host list and index name when you actually select elasticsearch.
Read vendor/locastic/loggastic/DependencyInjection/Configuration.php before writing YAML — the config tree is the authoritative source. The storage key is new in 2.1.0 and the README's "Choose your storage" section now documents it, but key names have still moved between minors and this bundle is small enough that the README lags the code.
Where to look first, in order
README.md — install, the new "Choose your storage" section, and a single annotated entity example. That's most of the mental model.Model/ActivityLog.php — the document/row shape (entityClass, entityId, action, changeSet, user, createdAt, etc.). Every backend stores this shape; everything downstream reads it.Storage/ or equivalent) — one implementation per backend. Read the Doctrine one if you're going relational: it tells you the exact table names, columns and JSON layout you'll be querying.Doctrine/ or EventListener/ — the onFlush subscriber that diffs your entities. Understanding this is what separates "it works" from "why is my relation change missing".DependencyInjection/Configuration.php + Resources/config/services.yaml — service IDs you can decorate, plus the table-name overrides for the DBAL adapter.First use case (30 minutes)
Write a throwaway test first: storage: in_memory, annotate one low-risk entity (not User), flush a create + an update, assert two log entries with the change set you expect. That's zero infrastructure and validates your annotations, your normalizer and your user provider before you touch a database or a cluster.
Then move the same test to storage: doctrine and query the tables directly to see the real row shape. Only then stand up Elasticsearch if you need search. Confirm three things at each step: the change set contains only the fields you expect, the user context is populated, and nothing recursive is being logged. Fixing a mapping (or a table schema) after real data exists is much harder than before.
Choose storage per environment, not per project. in_memory in the test suite, doctrine for local dev and small/medium deployments, elasticsearch where you actually need full-text, faceting or dashboards over millions of rows. Because the backend is a config line, this is easy — but keep the app talking to your own ActivityLoggerInterface, never to a backend-specific service, so the choice stays a config line.
Attribute-driven logging for Doctrine entities. Mark the entity (or individual properties) as loggable and the onFlush listener handles the rest: it compares getEntityChangeSet(), builds an ActivityLog, and hands it to the configured storage. Be selective. Annotate the properties that carry business meaning and let updatedAt, version and counters fall out. A change set of forty fields is not an audit trail, it's a diff dump — and with Doctrine storage those JSON columns land in your primary database, where bloat is your problem.
Manual logging for everything that isn't an entity write. Logins, exports, permission changes, queue-worker API calls, admin impersonation — none produce a Doctrine change set. Inject the activity-log handler and call it with an entity class/id, an action label, and a payload. Teams that only use the automatic listener end up with a log that's 90% entity updates and 0% of the events anyone asks about in an incident review.
Implement the current-user provider. The bundle can't know your user model. Provide a small service implementing its user-provider interface that returns an identifier and a type (App\Entity\Admin vs App\Entity\Customer) from the token storage. One class — impersonation or API-key principals later means changing one file.
Use the Doctrine backend's properties deliberately. Logs and current-data trackers live in two shared tables with JSON columns, discriminated by an indexed object class column. Table names are overridable via service config — do that if your DBA has naming conventions or a separate schema. Tracker writes are upserts, so retries and re-running the populate command are safe; that also means tracker rows are current state, not history, so don't build an audit trail out of them.
Design for the backend you'll end up on. If you're on doctrine, the queries you'll actually run are "everything about this order" and "everything this admin touched today" — make sure the object-class/id columns and your timestamp column are indexed, and plan retention (there's no ILM equivalent; write a pruning job). If you're on elasticsearch, set up an index template (shards, refresh interval, rollover) rather than letting dynamic mapping guess your id as text.
Async indexing — only where it applies. The Elasticsearch write happens inside the flush cycle, so it belongs in a Messenger message if ES is remote or your latency budget is tight: decorate the handler and dispatch instead of indexing. With the Doctrine backend the write is just another SQL statement in your existing transaction — usually fine, but see the failure-mode gotcha below.
Composition with existing audit tooling. If you already use gedmo/loggable or Doctrine's own audit, don't run both against the same entity. Pick one as the source of truth for change data and let Loggastic be the search/indexing layer — or, now that it can run on your DB, let it be the whole thing.
The old "search engine in your write path" problem is now a choice. With elasticsearch, ask what happens on flush when ES is slow or down, and test it in staging with ES stopped — an audit-log failure should almost never roll back a business transaction, and listener exception behaviour is version-dependent. With doctrine, the problem inverts: the log insert is now part of your database transaction, so a log failure can roll back the business write, and a rollback silently loses the audit entry. Decide which side you want to fail and test that too.
Switching backends is a config line; switching semantics is not. in_memory mirrors sorting, pagination and per-class isolation, and the Doctrine adapter passes values through DBAL type conversion with timestamps normalised to UTC — but date handling, JSON querying and text matching differ per backend. Any query in your app that isn't going through the bundle's API will need attention when you move from Doctrine to Elasticsearch (or vice versa).
Timestamps are normalised to UTC. Store nothing else, and be explicit about it in reporting queries and dashboards — a timestamptz-style assumption against a normalised column is a classic off-by-hours bug.
in_memory is per-process only. It keeps logs in the PHP process, so it will not survive between requests, queue workers or separate test cases that run in different processes. It's ideal for a single-process functional test and useless as a cross-request assertion store.
No ES services are registered on other backends. That's the clean part of the design and the only real upgrade risk: if your app decorates, type-hints or @required-injects an ES-specific service or the ES client, DI will fail the moment you flip storage. The same applies if you left elastic_* config keys around after switching — clean them up. Nothing breaks if you stay on the default, so treat this as a migration checklist item, not an upgrade hazard.
Check how the schema gets created. The Doctrine tables are created through the DBAL schema API — verify in your setup what the supported path is (a command, an explicit migration, or automatic creation) and put it in your deploy script. Do not rely on a table springing into existence on first write in production.
onFlush does not see everything. Doctrine's change set covers scalar field updates. Owning-side relation inserts, collection changes, cascade persists on nested objects, and anything written via raw DBAL or ->executeStatement() won't appear. Log those manually — and write a test that asserts the gap so a future developer doesn't assume it's covered.
Beware the self-referential loop. If the bundle's own log entity/table is in your Doctrine mapping (or you accidentally annotate it), you can get recursive logging or a write storm — more dangerous now, since with the Doctrine backend the recursion is inside your own database rather than an external index. Confirm your loggable configuration excludes it.
CLI and Messenger contexts have no user. security.tokenStorage is empty in a worker or console command, so user will be null on every log produced there. Accept it or thread a context object through the message and populate the provider from it — before you rely on the logs to answer "who did this" for background jobs.
Testing refresh semantics (Elasticsearch backend only). Default refresh is ~1s; write-then-search in the same test flakes. Use ?refresh=true/wait_for, or a dedicated test index with refresh_interval: -1 and manual refreshes — or just run the suite on in_memory and keep a small number of ES integration tests. Never point tests at the same index as dev.
Mapping changes are painful. Elasticsearch can't change a field type in place. Plan for reindexing: versioned index name (activity_log_v2) plus alias (activity_log) so you can rebuild without downtime. Decide early — retrofitting after six months of logs is a migration project. (Doctrine storage is easier here: add a column, write a migration.)
Debugging checklist. Confirm which backend is live (debug:config locastic_loggastic / debug:container for the handler service ID), confirm the listener is registered (debug:event-dispatcher | grep -i doctrine), and match the symptom to the backend: for Doctrine, query the two tables directly and check the write actually ran inside your transaction; for Elasticsearch, tail the cluster logs for 400s — a rejected document often manifests as a silently missing log if exceptions are swallowed. curl against _cat/indices?v and _mapping answers most "where did my log go" questions faster than reading Symfony code.
Sensitivity of the payload changes with the backend. A custom normalizer is still the right place to strip password hashes, tokens and PII — but note that storage: doctrine moves that data from a search cluster into your application database, where more tooling (backups, read replicas, ad-hoc SQL by anyone with a connection) can reach it. Neither backend is a reason to index $user->getPassword().
Maintenance risk is real, and slightly reduced. ~38 stars and zero known dependents means you're near the front of the adoption curve; 2.1.0 with a DBAL backend and CI across DBAL 3.8+/4.x and multiple databases is a genuine maturity signal, and being able to fall back to your own database is a useful hedge. Still: pin the exact version in composer.json, read upgrade diffs yourself, and wrap the handler behind your own ActivityLoggerInterface. That interface is cheap insurance, makes unit-testing logging calls trivial, and means a future storage change is a container binding rather than a refactor.
Extension points worth knowing. Custom normalizers/serializers for the payload, a decorated handler (async, filtering, or routing to a second backend), the user-provider interface for identity, and table-name overrides on the Doctrine adapter. The storage backend itself is now an extension point — it's worth reading one adapter implementation end-to-end so you know where the seams are.
How can I help you explore Laravel packages today?