incompass/timestampable-bundle
Symfony bundle that adds automatic timestamping to Doctrine entities, managing createdAt and updatedAt fields via listeners so you can track creation and modification times without boilerplate.
Before anything else, internalize the framework mismatch: this is a Symfony + Doctrine ORM bundle, not a Laravel package. There is no ServiceProvider, no Eloquent trait, no $table->timestamps() integration. If you are on Eloquent, stop reading and use native $timestamps; the rest of this document assumes you are either on Symfony/Doctrine or running Doctrine inside Laravel via laravel-doctrine/orm.
With that gate cleared:
Read the source first, not the README. The package has 0 stars, 0 dependents, and no observable maintenance. Open src/ on GitHub and confirm three things before installing: the exact bundle class name for registration, the trait/attribute names it expects, and which Doctrine events it subscribes to (prePersist, preUpdate, or a UnitOfWork filter based on a marker interface). Getting these wrong produces confusing "property already defined" or "no mapped field" errors that look like config problems but are API mismatches.
Install and register manually. No Flex recipe is expected for a package of this vintage:
composer require incompass/timestampable-bundle
// config/bundles.php
return [
// ...
Incompass\TimestampableBundle\TimestampableBundle::class => ['all' => true],
];
Spike a single low-risk entity. Do not convert your domain. Pick an append-only AuditLog, a Note, or a last_contacted_at field on a CRM record — somewhere a missed timestamp is a real bug but a failed spike is not an outage. Apply the trait plus the marker interface (or attribute, whichever the source uses):
#[ORM\Entity]
class AuditLog implements TimestampableInterface
{
use TimestampableTrait;
// $createdAt / $updatedAt supplied by the trait
}
Diff the schema, inspect the migration, then run it.
php bin/console doctrine:migrations:diff
# READ the generated file before running
php bin/console doctrine:migrations:migrate
Auto-mapped fields from a bundle can produce migrations that silently drop or re-type columns if your annotations and the trait's property types disagree.
Prove both events fire. Persist → flush → assert createdAt is populated. Update a scalar → flush → assert updatedAt advanced. If either is null, the subscriber is not registered or not matching — check config/bundles.php and the marker interface before debugging anything else.
First real use case: an append-only audit log where createdAt is the substrate for compliance evidence, or a last_contacted_at field powering staleness reports. Both are cases where manual assignment is noise and a missed update corrupts downstream reporting.
Fallbacks worth knowing before you commit: Gedmo/DoctrineExtensions via stof/doctrine-extensions-bundle (the de facto standard, decade of edge cases handled), Symfony's #[ORM\HasLifecycleCallbacks] with a preUpdate method, or a ~50-line AbstractTimestampedEntity base class you own outright. For a package with no adoption signal, the base class is often the correct call.
Trait + marker interface, per entity. The idiomatic shape for bundles like this is a marker interface (so the subscriber can filter $em->getUnitOfWork()->getScheduledEntityInsertions() cheaply) plus a trait supplying properties and getters. Repeat on each timestamped entity; do not try to attach it at a base-class level unless the source explicitly supports inheritance and you have tested it.
Keep trait getters untouched; add domain methods alongside.
class Invoice implements TimestampableInterface
{
use TimestampableTrait;
// Don't override getCreatedAt()/getUpdatedAt()
public function isStale(int $days = 30): bool
{
return $this->getUpdatedAt() < new \DateTimeImmutable("-{$days} days");
}
}
Overriding the trait's getters is how you break the subscriber's assumptions about field visibility and naming.
Check the property type for immutability. If the trait types fields as \DateTime rather than \DateTimeImmutable, you inherit mutable-object footguns — a caller can mutate $entity->getUpdatedAt() and quietly corrupt the value. If the setter is yours to write, copy on write:
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt === null
? null
: \DateTimeImmutable::createFromMutable($this->createdAt);
}
Bulk operations bypass everything. Doctrine listeners fire on prePersist/preUpdate from the UnitOfWork; they do not fire for UPDATE ... SET written as DQL, EntityManager::createQueryBuilder()->update(), or raw SQL. If your app has admin tooling, queued ETL, or import jobs, those paths will silently leave updated_at stale. The fix is discipline, not config: either route bulk writes through a repository method that explicitly sets updatedAt, or add a DB-level ON UPDATE CURRENT_TIMESTAMP for the cases you cannot control. Document this in the entity's docblock — it is the single most likely production failure.
Timezone: force UTC end to end. Set date_default_timezone_set('UTC') in the app kernel (or php.ini), use DATETIME columns (not TIMESTAMP unless you have read and accepted the timezone-translation semantics), and never let a \DateTime be constructed without an explicit timezone. Mixed-timezone bugs show up as reports that are "off by a few hours" only during DST — very expensive to diagnose after the fact.
Index what you query on. If you build reports like "records untouched in 90 days," index updated_at. On high-write tables, remember that an indexed timestamp column adds write amplification — measure before adding indexes to every table.
Blameable is a separate purchase. If the roadmap includes created_by/updated_by, do not adopt this package and later swap. Decide up front: Gedmo Blameable, an in-house listener adding both, or a scoped phase-2 that accepts a later refactor.
Vendoring is the safe way to use this. Because upstream has no maintainers, fork it into your monorepo (confirm the license permits this — the source does not clearly evidence a license choice; check LICENSE before assuming MIT). Treat it as a code sample you have adopted, and treat any upstream absence of releases as a non-event.
Silent no-op on registration errors. If the bundle is registered but autoconfiguration is disabled, or the marker interface is misspelled, no error surfaces — timestamps are simply null. This is the most common failure. Verify with a test, not by eyeballing the entity.
Doctrine ORM 2 vs 3 listener signatures differ. ORM 3 changed event subscriber argument types. Before installing, confirm the source targets your ORM major. A mismatch is a stop sign, not a patch you want to own.
No compatibility pressure from downstream. With zero dependents, there is no one to catch a regression against your Symfony or Doctrine version. Pin the version in composer.json and add a smoke test to your CI so an unnoticed composer update cannot break timestamps silently.
Pre-migration rows are null. Adding created_at/updated_at to a large existing table produces null values for historical rows. Either backfill from a known source (often id-based ordering or a data-warehouse snapshot), or accept "unknown" in your reports. On large tables, backfill in batches and make the column nullable-then-not-nullable in separate migrations to keep the schema change online-safe.
updated_at reflects commit order, not logical order. Concurrent transactions can commit out of order, so two rows touched in logical sequence may show updated_at values that look reversed. Do not build audit trails on top of updated_at ordering — use an append-only log with a monotonic key for that.
Config quirks. "Configurable field names" usually means you can rename created_at/updated_at, but the trait still supplies the properties under its own names. If you configure the bundle to use inserted_on/modified_on, you must either alias the properties or the mapped column names will not match what the subscriber writes. Read Resources/config/services.yaml (or equivalent) to see what is actually configurable versus fixed.
Extension points are probably UnitOfWork listeners, not an event bus. If you need to hook in extra behavior (notifications on update, cache invalidation), do it in the repository layer rather than trying to intercept the bundle's listener — it will not have designed for extension.
Doctrine second-level cache and entity managers. Timestamps updated by the subscriber are only visible after flush() and cache invalidation. If you are reading via a cached query on the same entity, stale values are expected.
Debugging checklist when a timestamp is wrong:
config/bundles.php with ['all' => true]?EntityManager::persist() + flush(), or through DQL/bulk SQL?datetime or datetime_immutable — matching the trait?date_default_timezone_set consistent between app and DB session?When not to adopt: any need for created_by/updated_by, soft-delete timestamps, onCreate-only semantics, or timezone-aware variants. Use Gedmo Timestampable/Blameable or write a trait. The cost of the trait is a few hours; the cost of owning an unmaintained dependency is unbounded.
One-line summary for a colleague: it is a Symfony/Doctrine listener plus a trait, the surface area is tiny, and the logic is small enough that writing it in-house is usually the right call — read this package as a reference implementation, then build your own base class.
How can I help you explore Laravel packages today?