letkode / orm-toolkit-bundle
Doctrine entity traits, value objects and DQL utilities for Symfony applications
Package info
github.com/letkode/orm-toolkit-bundle
Type:symfony-bundle
pkg:composer/letkode/orm-toolkit-bundle
Requires
- php: ^8.4
- doctrine/doctrine-bundle: ^3.0
- doctrine/orm: ^3.0
- gedmo/doctrine-extensions: ^3.0
- letkode/http-exception-bundle: ^1.2
- letkode/list-query-bundle: ^1.0
- symfony/serializer: ^7.0 || ^8.0
- symfony/uid: ^7.0 || ^8.0
- symfony/validator: ^7.0 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2
- phpunit/phpunit: ^11
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Doctrine entity traits, repository traits, value objects and DQL utilities for Symfony applications.
Installation
composer require letkode/orm-toolkit-bundle
Symfony Flex will register the bundle automatically. If not using Flex, add it manually:
// config/bundles.php return [ Letkode\OrmToolkitBundle\LetkodeOrmToolkitBundle::class => ['all' => true], ];
The bundle automatically registers the TRANSLATE_FIELD_VALUE Doctrine DQL function via PrependExtensionInterface.
Entity Traits
UuidTrait
Adds a uuid column (UUIDv7) with a PostgreSQL uuidv7() default. The UuidGeneratorSubscriber ensures PHP-side generation before persist so Gedmo Loggable captures the value.
use Letkode\OrmToolkitBundle\Trait\Entity\UuidTrait; #[ORM\Entity] class Product { use UuidTrait; }
HasTranslationsTraitmoved toletkode/locale-bundle(Letkode\LocaleBundle\Trait\HasTranslationsTrait) — see that package's README.doctrine/ormissuggest-only there, so it stays an opt-in dependency for apps that don't need entity-level translations.
ParameterTrait
Adds a parameters jsonb column with recursive merge support.
$entity->setParameter('color', 'red'); $entity->getParameter('color'); // 'red' $entity->setParameters(['size' => 'L'], force: false); // recursive merge
ObjectTrackNullableTrait / ObjectTrackRequiredTrait
Adds objectClass and objectId columns to track which object a record belongs to. Use the nullable variant when the relation is optional.
Repository Traits
BaseRepositoryTrait
Query/filter/pagination params are provided by letkode/list-query-bundle.
use Letkode\ListQueryBundle\Request\ListQueryRequest; use Letkode\ListQueryBundle\Sort\SortInput; $repo->save($entity); $repo->remove($entity); $repo->findByUuid($uuid); // returns T|null $repo->findOrFailByUuid($uuid); // throws Letkode\HttpExceptionBundle\Exception\EntityNotFoundException (404) $repo->paginate($qb, $query, sortable: ['name' => SortInput::field()], searchable: ['name', 'email']); // $query is a Letkode\ListQueryBundle\Request\ListQueryRequest
searchable entries may be a qualified alias path ('p.lastName') instead of a bare field name, to
search on a joined entity:
$qb = $this->createQueryBuilder('c')->join('c.person', 'p'); $repo->paginate($qb, $query, searchable: ['p.firstName', 'p.lastName', 'p.email']);
sortable is declared like filterable: the array key is the public name the client sends, and the
value is a SortInput that names the alias that owns
the field, so the query alias never reaches the API. Every entry must be a SortInput (and every
filterable entry a FilterInput); anything else throws an InvalidArgumentException.
$repo->paginate($qb, $query, sortable: [ 'createdAt' => SortInput::field(), // root alias 'legalName' => SortInput::field(alias: 'c'), // ?sort=legalName -> c.legalName 'company_name' => SortInput::field(alias: 'c', property: 'name'), // explicit property ]);
The property defaults to the key converted to the configured property_case. A SortInput is
matched by its key only: the qualified path (c.legalName) is not accepted.
A bare searchable field name (no dot) resolves against the root alias. The referenced alias must
already be joined on the QueryBuilder passed to paginate() — the bundle does not validate or
infer joins, same responsibility as FilterInput::alias. Ordering on a path behind a to-many join
can duplicate rows in the paginated result (the usual Doctrine to-many fan-out); prefer
sorting/searching on a to-one relation, or on a column of the root entity, when possible.
Strict mode
paginate() validates the request against the declared allowlists before touching the database.
By default (strict: true) an undeclared sort field, an undeclared filter field, an unknown filter
operator or a malformed filter entry throws Letkode\ListQueryBundle\Exception\UndeclaredQueryParameterException,
which carries the full list of rejections (->rejections) so the caller can report them all at once
and it is an HTTP status exception (422, with the errors by parameter), so the ExceptionListener of
letkode/http-exception-bundle renders it without any extra code. A q shorter than
minSearchLength is not a rejection — search is simply not applied.
$repo->paginate($qb, $query, sortable: ['name' => SortInput::field()], strict: false); // silently ignore rejections
TranslatableRepositoryTrait
$this->addTranslatedOrderBy($qb, 'p', 'name', $locale, 'ASC'); $this->addTranslatedSearch($qb, 'p', 'name', $searchTerm, $locale);
Value Objects
All value objects are final readonly, normalize on construction and throw ValueObjectException on invalid input.
| Class | Validates |
|---|---|
Email |
Valid email, lowercased |
Phone |
E.164-compatible (strips spaces/dashes) |
Slug |
Lowercase, [a-z0-9-], 2–255 chars |
Username |
[a-zA-Z0-9_.-], 3–50 chars |
$email = new Email(' USER@Example.COM '); // 'user@example.com' $slug = new Slug('My Product Name'); // 'my-product-name'
DQL Function
TRANSLATE_FIELD_VALUE(column, 'field', :locale) maps to jsonb_extract_path_text(column, locale, field).
Useful for ordering and filtering on translated values stored in a jsonb translations column.
Requirements
- PHP
^8.4 - Symfony
^7.0 || ^8.0 doctrine/orm^3.0doctrine/doctrine-bundle^3.0gedmo/doctrine-extensions^3.0letkode/http-exception-bundle^1.2letkode/list-query-bundle^1.0
License
MIT — see LICENSE.
Property case
A filter's target is built as alias.property: the input's alias (the root alias when omitted) and
its property (the key converted to your entity properties' spelling when omitted). The
property_case option and the per-input propertyCase argument belong to
letkode/list-query-bundle (letkode_list_query.property_case); see its README.
'legal_name' => FilterInput::text() then filters on <root>.legalName,
FilterInput::text(alias: 'c') on c.legalName, and FilterInput::text(expression: "CONCAT(u.firstName, ' ', u.lastName)")
filters on that DQL expression as-is.