danilovl/entity-traits-bundle

Symfony bundle providing reusable Doctrine ORM entity traits, interfaces, listeners and validators to eliminate Entity boilerplate.

Maintainers

Package info

github.com/danilovl/entity-traits-bundle

Type:symfony-bundle

pkg:composer/danilovl/entity-traits-bundle

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.0.1 2026-04-26 12:33 UTC

This package is auto-updated.

Last update: 2026-04-26 12:36:11 UTC


README

phpunit downloads latest Stable Version license

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:

  • 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].

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

  • GenderMale, Female, NonBinary, Other, PreferNotToSay
  • IdentityStrategyInt, Uuid, Ulid
  • WorkflowStateDraft, Pending, InReview, Approved, Published, Rejected, Archived, Cancelled
  • RobotsDirectiveIndex, 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.