danilovl/entity-traits-bundle
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, …).
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
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.
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:
TimestampableListener populates createdAt / updatedAt in the configured timezone.SluggableListener generates the slug from title / name when empty.BlameableListener sets createdBy / updatedBy from the current Security user (object or string identifier).SoftDeletableListener converts $em->remove($entity) into UPDATE … SET deleted_at = NOW().RequestContextListener populates ipAddress / userAgent from the current Request.LastLoginAtListener updates lastLoginAt on LoginSuccessEvent.RevisionListener increments revision on every onFlush update.TreePathListener rebuilds materialized path / depth for entities marked with #[Tree].TagsNormalizationListener trims, lowercases and dedupes tags arrays.AutoIdentifierListener fills properties marked with #[AutoToken], #[AutoUuid], #[AutoUlid], #[AutoIdentifier].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 |
| 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(). |
| 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. |
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.
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');
Gender — Male, Female, NonBinary, Other, PreferNotToSayIdentityStrategy — Int, Uuid, UlidWorkflowState — Draft, Pending, InReview, Approved, Published, Rejected, Archived, CancelledRobotsDirective — Index, NoIndex, Follow, NoFollow, IndexFollow, NoIndexNoFollow, NoArchive, NoSnippetBundle 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 |
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.
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 { /* ... */ }
#[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.
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.
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.
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.
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.
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
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
}
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
}
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
}
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)
}
Fine-grained address decomposition, separate from the combined AddressTrait:
use Danilovl\EntityTraitsBundle\Trait\Optional\Geolocation\{
RegionTrait,
CityTrait,
PostalCodeTrait,
AddressLineTrait
};
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
};
The EntityTraitsBundle is open-sourced software licensed under the MIT license.
How can I help you explore Laravel packages today?