danilovl / entity-traits-bundle
Symfony bundle providing reusable Doctrine ORM entity traits, interfaces, listeners and validators to eliminate Entity boilerplate.
Package info
github.com/danilovl/entity-traits-bundle
Type:symfony-bundle
pkg:composer/danilovl/entity-traits-bundle
Requires
- php: ^8.5
- ext-ctype: *
- ext-mbstring: *
- doctrine/dbal: ^4.0
- psr/clock: ^1.0
- symfony/clock: ^8.0
- symfony/config: ^8.0
- symfony/dependency-injection: ^8.0
- symfony/http-kernel: ^8.0
- symfony/security-bundle: ^8.0
- symfony/security-core: ^8.0
- symfony/string: ^8.0
- symfony/uid: ^8.0
- symfony/validator: ^8.0
Requires (Dev)
- doctrine/orm: ^3.0.0
- doctrine/persistence: ^4.0.0
- friendsofphp/php-cs-fixer: ^3.92.5
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan: ^2.1.33
- phpstan/phpstan-symfony: ^2.0.9
- phpunit/phpunit: ^12.5.5
- symfony/framework-bundle: ^8.0
- symfony/http-foundation: ^8.0
- symfony/intl: ^8.0
- symfony/yaml: ^8.0
README
EntityTraitsBundle
Symfony bundle providing 410 Doctrine ORM trait variants across 21 categories — plus interfaces, listeners, filters, validators and attributes — to eliminate the boilerplate that gets copy-pasted across every entity (id, createdAt, updatedAt, slug, active, deletedAt, …).
Requirements
- PHP 8.5+
- Symfony 8.0
- Doctrine ORM 3.0
Installation
composer require danilovl/entity-traits-bundle
If you are not using Symfony Flex, register the bundle in config/bundles.php:
return [ // ... Danilovl\EntityTraitsBundle\EntityTraitsBundle::class => ['all' => true], ];
Then create config/packages/danilovl_entity_traits.yaml:
danilovl_entity_traits: identity: default_strategy: int # int | uuid | ulid — used by #[AutoIdentifier] timestampable: enabled: true timezone: 'UTC' # timezone applied to all auto-timestamps blameable: enabled: false user_class: App\Entity\User # required when enabled use_username_string: false # store getUserIdentifier() string instead of User object sluggable: enabled: true fallback_field: 'name' soft_delete: enabled: true filter_auto_enable: true # auto-registers SoftDeleteFilter in Doctrine request_context: user_agent_max_length: 500 last_login_at: throttle_seconds: 0 # skip update if last login is within N seconds tags: lowercase: true
Required vs Optional variants
Every trait and *AwareInterface ships in two variants, mirroring whether the underlying field is mandatory or optional at the database level:
| Variant | Folder | Doctrine | PHP type |
|---|---|---|---|
Required |
Trait/Required/<Group>/ |
nullable: false |
non-nullable (string, int, …) |
Optional |
Trait/Optional/<Group>/ |
nullable: true |
nullable (?string, ?int, …) |
Pick whichever matches your column constraint. You can mix freely within an entity:
use Danilovl\EntityTraitsBundle\Trait\Required\Content\NameTrait; // string $name use Danilovl\EntityTraitsBundle\Trait\Optional\Content\TitleTrait; // ?string $title = null
The same split applies to interfaces under Contract/Required/<Group>/ and Contract/Optional/<Group>/. Listeners and filters in this bundle accept both variants — they instanceof-check both contract namespaces internally, so user entities are free to pick either side.
Quick start
namespace App\Entity; use Danilovl\EntityTraitsBundle\Contract\Optional\Audit\BlameableInterface; use Danilovl\EntityTraitsBundle\Contract\Optional\Timestampable\TimestampableInterface; use Danilovl\EntityTraitsBundle\Trait\Optional\Audit\BlameableTrait; use Danilovl\EntityTraitsBundle\Trait\Optional\Content\{ContentTrait, TitleTrait}; use Danilovl\EntityTraitsBundle\Trait\Optional\Counter\ViewsCountTrait; use Danilovl\EntityTraitsBundle\Trait\Required\Identity\UuidTrait; use Danilovl\EntityTraitsBundle\Trait\Optional\Seo\{MetaTrait, SlugTrait}; use Danilovl\EntityTraitsBundle\Trait\Optional\Timestampable\{PublishedAtTrait, TimestampableTrait}; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] #[ORM\Table(name: 'articles')] class Article implements TimestampableInterface, BlameableInterface { use UuidTrait; use TitleTrait; use SlugTrait; use ContentTrait; use MetaTrait; use PublishedAtTrait; use TimestampableTrait; use BlameableTrait; use ViewsCountTrait; }
That's an entity with 11 columns and 30+ helper methods (getCreatedAt(), setSlug(), publish(), isPublished(), incrementViewsCount(), …) without writing a single field by hand.
The bundle's listeners do the rest:
TimestampableListenerpopulatescreatedAt/updatedAtin the configured timezone.SluggableListenergenerates the slug fromtitle/namewhen empty.BlameableListenersetscreatedBy/updatedByfrom the current Security user (object or string identifier).SoftDeletableListenerconverts$em->remove($entity)intoUPDATE … SET deleted_at = NOW().RequestContextListenerpopulatesipAddress/userAgentfrom the current Request.LastLoginAtListenerupdateslastLoginAtonLoginSuccessEvent.RevisionListenerincrementsrevisionon everyonFlushupdate.TreePathListenerrebuilds materializedpath/depthfor entities marked with#[Tree].TagsNormalizationListenertrims, lowercases and dedupestagsarrays.AutoIdentifierListenerfills properties marked with#[AutoToken],#[AutoUuid],#[AutoUlid],#[AutoIdentifier].
Trait catalog
205 Optional + 205 Required = 410 trait variants across 21 categories. Every trait has a paired *AwareInterface under Contract/Required/<Group>/ and Contract/Optional/<Group>/.
| Group | Traits |
|---|---|
| Identity | IdTrait, UuidTrait, UlidTrait |
| Timestampable | CreatedAtTrait, UpdatedAtTrait, DeletedAtTrait, PublishedAtTrait, StartAtTrait, EndAtTrait, ExpiresAtTrait, ConfirmedAtTrait, ApprovedAtTrait, LastLoginAtTrait, ArchivedAtTrait, LockedAtTrait, ScheduledAtTrait, ProcessedAtTrait, CompletedAtTrait, FailedAtTrait, SentAtTrait, ReadAtTrait, AcceptedAtTrait, RejectedAtTrait, SuspendedAtTrait, RetiredAtTrait, TrialEndsAtTrait, SubscribedAtTrait, UnsubscribedAtTrait, NextBillingAtTrait, VerifiedAtTrait, BannedAtTrait, TimestampableTrait, FullTimestampableTrait, SoftDeletableTrait |
| Status / Flags | ActiveTrait, EnabledTrait, VisibleTrait, FeaturedTrait, PinnedTrait, VerifiedTrait, PublicTrait, WorkflowTrait, DraftTrait, SuspendedTrait, BannedTrait, ReadTrait, ProcessedTrait, ApprovedTrait, SpamTrait, LockedTrait |
| Content | NameTrait, TitleTrait, SubtitleTrait, DescriptionTrait, ContentTrait, ExcerptTrait, NotesTrait, CodeTrait, ReferenceTrait |
| People | FirstNameTrait, LastNameTrait, MiddleNameTrait, FullNameTrait, AcademicDegreeTrait, BirthdayTrait, GenderTrait, NationalityTrait, AvatarTrait, UsernameTrait, DisplayNameTrait, NicknameTrait, HeadlineTrait, PronounsTrait, BiographyTrait |
| Contact | EmailTrait, EmailConfirmableTrait, PhoneTrait, MobilePhoneTrait, WebsiteTrait, SocialLinksTrait, AddressTrait, SecondaryEmailTrait, StreetAddressTrait, EmergencyContactTrait |
| Geographic | LocationTrait, TimezoneTrait, LocaleTrait, CountryTrait, CurrencyTrait |
| Geolocation | RegionTrait, CityTrait, PostalCodeTrait, AddressLineTrait |
| Media | ImageTrait, ThumbnailTrait, FileTrait, VideoTrait, AudioTrait, AltTextTrait, CaptionTrait, DurationTrait, AttachmentsTrait, GalleryTrait |
| SEO | SlugTrait, MetaTrait, OgMetaTrait, CanonicalUrlTrait, RobotsTrait, TwitterCardTrait, SitemapTrait, HreflangTrait, SchemaOrgTrait, SlugAliasTrait |
| Business | PriceTrait, SkuTrait, BarcodeTrait, GtinTrait, IsbnTrait, StockTrait, WeightTrait, DimensionsTrait, TaxRateTrait, VatNumberTrait, DiscountTrait, CompareAtPriceTrait, CostPriceTrait, MinimumQuantityTrait, MaximumQuantityTrait, ReorderLevelTrait, UnitTrait, ManufacturerTrait, BrandTrait, ModelTrait, MpnTrait |
| Pricing | PriceRangeTrait, SubscriptionPriceTrait, TierPricingTrait |
| Sorting / Hierarchy | PriorityTrait, PositionTrait, LevelTrait, TreePathTrait, ParentTrait |
| Audit / Blameable | CreatedByTrait, UpdatedByTrait, DeletedByTrait, BlameableTrait, CreatedByStringTrait, UpdatedByStringTrait, OwnerTrait, AuthorTrait, IpAddressTrait, UserAgentTrait |
| Versioning | VersionTrait, RevisionTrait |
| Counters | ViewsCountTrait, LikesCountTrait, SharesCountTrait, CommentsCountTrait, DownloadsCountTrait, RatingTrait, FavoritesCountTrait, BookmarksCountTrait, FollowersCountTrait, FollowingCountTrait, SubscribersCountTrait, ClicksCountTrait, DislikesCountTrait, ErrorsCountTrait, PointsTrait, BalanceTrait |
| Misc | ColorTrait, IconTrait, TagsTrait, MetadataTrait, TokenTrait, HashTrait, SortKeyTrait, ChecksumTrait, LabelTrait, BadgeTrait, ExtrasTrait, SettingsTrait, FingerprintTrait, SecretTrait, EncryptedPayloadTrait, OriginalUrlTrait |
| Security | FailedLoginAttemptsTrait, LockedUntilTrait, TwoFactorEnabledTrait, ApiKeyTrait, PasswordChangedAtTrait, RememberTokenTrait |
| Tenancy | TenantTrait, OrganizationTrait, TeamTrait, WorkspaceTrait |
| Integration | ExternalIdTrait, ExternalSourceTrait, ExternalUrlTrait, ImportedAtTrait, SyncedAtTrait |
| Presets | BlogPostTrait, EcommerceProductTrait, UserAccountTrait, EventTrait |
Conventions
| Topic | Rule |
|---|---|
| Date type | Always \DateTimeImmutable. Doctrine column type is datetime_immutable / date_immutable. |
| Setter return | static (fluent), never void. |
| Property visibility | protected, so subclasses (e.g. STI / MappedSuperclass heirs) can adjust mapping. |
| Lifecycle logic | Lives in EventListener/, not inside traits. |
| Boolean methods | isX() / hasX(), never getX(). |
| Identity strategy | Pick one of IdTrait / UuidTrait / UlidTrait per entity. |
Required IdTrait |
Property is uninitialized typed (int $id;); use isNew() (which uses isset) before reading getId(). |
Attributes
| Attribute | Effect |
|---|---|
#[AutoSlug(from: 'title')] |
Class-level. Tells SluggableListener which field to slug from. |
#[AutoToken(property: 'apiKey', length: 32)] |
Generates a hex token (bin2hex(random_bytes())) on prePersist if empty. |
#[AutoUuid] / #[AutoUlid] |
Generates a UUID v7 / ULID for non-PK columns on prePersist. |
#[AutoIdentifier(property: 'externalId')] |
Generates a UUID v7 or ULID based on identity.default_strategy. Skipped for int. |
#[Tree] |
Marks a ParentTrait + TreePathTrait entity so TreePathListener rebuilds path / depth automatically. |
Validators
use Danilovl\EntityTraitsBundle\Trait\Optional\Geographic\LocationTrait; use Danilovl\EntityTraitsBundle\Validator\{ Barcode, Country, Currency, HexColor, Iban, Isbn, LatitudeRange, Locale, LongitudeRange, Timezone, VatNumber }; class Product { use LocationTrait; #[LatitudeRange] protected ?float $latitude = null; #[LongitudeRange] protected ?float $longitude = null; #[HexColor] protected ?string $brandColor = null; #[Iban] protected ?string $iban = null; #[VatNumber] protected ?string $vatNumber = null; #[Isbn] protected ?string $isbn = null; #[Barcode] // EAN-8/EAN-13/UPC-A/GTIN-14 with checksum protected ?string $barcode = null; #[Currency] // ISO 4217 protected ?string $currency = null; #[Country] // ISO 3166-1 alpha-2 protected ?string $country = null; #[Locale] // BCP 47 protected ?string $locale = null; #[Timezone] // IANA TZDB protected ?string $timezone = null; }
All validators are registered as services with the validator.constraint_validator tag — Symfony's validator pipeline picks them up automatically.
Doctrine SQL filters
soft_delete is registered automatically when soft_delete.filter_auto_enable: true (the default). The remaining filters must be registered manually.
| Filter | Auto-registered | Hides rows where… |
|---|---|---|
SoftDeleteFilter |
✅ by default | deleted_at IS NOT NULL |
PublishedFilter |
❌ manual | published_at IS NULL OR published_at > NOW() |
ActiveFilter |
❌ manual | active = false |
ArchivedFilter |
❌ manual | archived_at IS NOT NULL |
To disable auto-registration of SoftDeleteFilter:
danilovl_entity_traits: soft_delete: filter_auto_enable: false
To register the other filters manually:
doctrine: orm: filters: published: class: Danilovl\EntityTraitsBundle\Doctrine\Filter\PublishedFilter enabled: false
$em->getFilters()->enable('published');
Enums
Gender—Male,Female,NonBinary,Other,PreferNotToSayIdentityStrategy—Int,Uuid,UlidWorkflowState—Draft,Pending,InReview,Approved,Published,Rejected,Archived,CancelledRobotsDirective—Index,NoIndex,Follow,NoFollow,IndexFollow,NoIndexNoFollow,NoArchive,NoSnippet
Configuration as a service
Bundle config flows into a typed, autowireable EntityTraitsConfig value object:
use Danilovl\EntityTraitsBundle\DependencyInjection\Config\EntityTraitsConfig; final readonly class Audit { public function __construct(private EntityTraitsConfig $config) {} }
| Field | Type | Config key |
|---|---|---|
blameableUserClass |
string |
blameable.user_class |
blameableUseUsernameString |
bool |
blameable.use_username_string |
sluggableFallbackField |
string |
sluggable.fallback_field |
userAgentMaxLength |
int |
request_context.user_agent_max_length |
lastLoginAtThrottleSeconds |
int |
last_login_at.throttle_seconds |
tagsNormalizationLowercase |
bool |
tags.lowercase |
identityDefaultStrategy |
string |
identity.default_strategy |
timestampableTimezone |
string |
timestampable.timezone |
Timestampable timezone
All auto-timestamps are converted to the configured timezone before being stored:
danilovl_entity_traits: timestampable: timezone: 'Europe/Berlin'
TimestampableListener calls $clock->now()->setTimezone(new DateTimeZone($config->timestampableTimezone)) — so the stored DateTimeImmutable carries the correct offset even when the app server is in UTC.
Slug source override
By default SluggableListener uses the name field. To slug from title (or any other field):
use Danilovl\EntityTraitsBundle\Attribute\AutoSlug; #[ORM\Entity] #[AutoSlug(from: 'title')] class Article { /* ... */ }
Auto-identifier with configured strategy
#[AutoIdentifier] generates a UUID or ULID depending on identity.default_strategy, making the choice central rather than per-entity:
danilovl_entity_traits: identity: default_strategy: uuid # uuid | ulid (int = no generation)
use Danilovl\EntityTraitsBundle\Attribute\AutoIdentifier; #[AutoIdentifier(property: 'externalId')] class Order { private ?object $externalId = null; }
On prePersist, AutoIdentifierListener generates Uuid::v7() (or new Ulid) and fills the property if it is empty. For int, no generation is performed — Doctrine handles auto-increment at the database level.
Soft delete
use Danilovl\EntityTraitsBundle\Contract\Optional\Timestampable\SoftDeletableInterface; use Danilovl\EntityTraitsBundle\Trait\Required\Identity\UuidTrait; use Danilovl\EntityTraitsBundle\Trait\Optional\Timestampable\SoftDeletableTrait; class User implements SoftDeletableInterface { use UuidTrait; use SoftDeletableTrait; // ... } // Anywhere in your app: $em->remove($user); // -> issues UPDATE users SET deleted_at = NOW() $em->flush();
SoftDeleteFilter is auto-registered and enabled by default (see soft_delete.filter_auto_enable). All subsequent SELECTs hide the row automatically. To include deleted rows:
$em->getFilters()->disable('soft_delete');
If the entity also implements DeletedByAwareInterface, the listener fills deletedBy from the current Security user during the same flush.
Blameable — object vs string
By default BlameableListener stores the full UserInterface object as a Doctrine relation. Use use_username_string: true to store the username string instead, which avoids a foreign key and is useful for audit logs, microservices, or any context where the user relation is not available:
danilovl_entity_traits: blameable: enabled: true use_username_string: true
use Danilovl\EntityTraitsBundle\Contract\Optional\Audit\{ CreatedByStringAwareInterface, UpdatedByStringAwareInterface }; use Danilovl\EntityTraitsBundle\Trait\Optional\Audit\{ CreatedByStringTrait, UpdatedByStringTrait }; class Article implements CreatedByStringAwareInterface, UpdatedByStringAwareInterface { use CreatedByStringTrait; // string $createdBy column use UpdatedByStringTrait; // string $updatedBy column }
BlameableListener detects the interface variant at runtime: when use_username_string = true it calls $entity->setCreatedBy($user->getUserIdentifier()) instead of $entity->setCreatedBy($user).
The standard object-based traits (CreatedByTrait, UpdatedByTrait) remain unchanged and work as before when the flag is false.
Tree (materialized path)
use Danilovl\EntityTraitsBundle\Attribute\Tree; use Danilovl\EntityTraitsBundle\Trait\Optional\Sorting\{ParentTrait, TreePathTrait}; use Danilovl\EntityTraitsBundle\Trait\Required\Identity\IdTrait; #[ORM\Entity] #[Tree(separator: '/', pathProperty: 'path', depthProperty: 'depth', identifier: 'id')] class Category { use IdTrait; use ParentTrait; use TreePathTrait; }
TreePathListener rebuilds path (e.g. /1/4/9) and depth on every save by walking the parent chain.
Testing
composer install composer tests # phpunit (Unit + Functional) composer phpstan # static analysis composer cs-fixer-check # php-cs-fixer dry run
Functional tests use a minimal TestKernel (under tests/Functional/Kernel/TestKernel.php) that boots only FrameworkBundle + EntityTraitsBundle so wiring scenarios can be verified in isolation.
Security traits
Prevent brute-force and track authentication state:
use Danilovl\EntityTraitsBundle\Trait\Optional\Security\{ FailedLoginAttemptsTrait, LockedUntilTrait, TwoFactorEnabledTrait, PasswordChangedAtTrait }; class User { use FailedLoginAttemptsTrait; // $failedLoginAttempts, $lastFailedLoginAt use LockedUntilTrait; // $lockedUntil + isCurrentlyLocked() use TwoFactorEnabledTrait; // $twoFactorEnabled, $twoFactorSecret use PasswordChangedAtTrait; // $passwordChangedAt } $user->incrementFailedLoginAttempts(); // bumps counter + sets lastFailedLoginAt $user->resetFailedLoginAttempts(); // zeroes counter $user->isCurrentlyLocked(); // true when lockedUntil > now
Tenancy traits
Add multi-tenancy scoping with a single trait:
use Danilovl\EntityTraitsBundle\Trait\Required\Tenancy\TenantTrait; class Invoice { use TenantTrait; // int $tenantId use OrganizationTrait; // int $organizationId }
Integration traits
Track external system references without coupling your schema to foreign systems:
use Danilovl\EntityTraitsBundle\Trait\Optional\Integration\{ ExternalIdTrait, ExternalSourceTrait, ExternalUrlTrait, ImportedAtTrait, SyncedAtTrait }; class Product { use ExternalIdTrait; // $externalId — ID in the foreign system use ExternalSourceTrait; // $externalSource — e.g. 'shopify', 'magento' use ImportedAtTrait; // $importedAt use SyncedAtTrait; // $syncedAt }
Pricing traits
Separate pricing concerns from the base product:
use Danilovl\EntityTraitsBundle\Trait\Optional\Pricing\{ PriceRangeTrait, SubscriptionPriceTrait, TierPricingTrait }; class Plan { use SubscriptionPriceTrait; // $monthlyPrice, $annualPrice use PriceRangeTrait; // $priceMin, $priceMax }
Extended counters
use Danilovl\EntityTraitsBundle\Trait\Optional\Counter\{ FavoritesCountTrait, FollowersCountTrait, PointsTrait, BalanceTrait }; class UserProfile { use FavoritesCountTrait; // incrementFavoritesCount() / decrementFavoritesCount() use FollowersCountTrait; use PointsTrait; // loyalty / gamification points use BalanceTrait; // wallet balance (in cents) }
Geolocation traits
Fine-grained address decomposition, separate from the combined AddressTrait:
use Danilovl\EntityTraitsBundle\Trait\Optional\Geolocation\{ RegionTrait, CityTrait, PostalCodeTrait, AddressLineTrait };
Extended SEO
use Danilovl\EntityTraitsBundle\Trait\Optional\Seo\{ TwitterCardTrait, // twitterCard, twitterTitle, twitterDescription, twitterImage SitemapTrait, // sitemapPriority (float), sitemapChangefreq HreflangTrait, // hreflang JSON map — setHreflangLocale('de', '/de/page') SchemaOrgTrait, // schemaOrg JSON (JSON-LD structured data) SlugAliasTrait // slugAlias for redirect from old URLs };
License
The EntityTraitsBundle is open-sourced software licensed under the MIT license.