raksul/doctrine-set-type-bundle
A Symfony bundle that provides a Doctrine DBAL type for MySQL SET columns, enabling entities to map set-valued database fields to PHP arrays and simplifying Doctrine ORM integration for applications that rely on MySQL SET types.
The bundle is a thin wrapper that registers a custom Doctrine DBAL type (set) so Doctrine can map MySQL SET columns to PHP arrays.
Installation
composer require raksul/doctrine-set-type-bundle
Register the bundle in config/bundles.php (Symfony Flex may do this for you):
return [
// ...
Raksul\DoctrineSetTypeBundle\DoctrineSetTypeBundle::class => ['all' => true],
];
Where to look first
DoctrineSetType (or similarly named) class under src/ — this is the only place real logic lives, and it tells you exactly which column options key the type reads for the allowed values.DependencyInjection/ compiler pass that registers the type with Doctrine — confirms the type name (usually set).options shape for the column definition.First use case
Add a SET column to an entity and map it to an array:
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Article
{
#[ORM\Column(
type: 'set',
options: ['values' => ['draft', 'review', 'published', 'archived']]
)]
private array $statuses = [];
}
Then run bin/console doctrine:schema:validate and doctrine:migrations:diff to confirm a SET('draft','review','published','archived') column is emitted. Read/write through the property and you get a PHP array; the bundle handles the a,b,c string conversion.
Attribute mapping (preferred in modern Symfony)
#[ORM\Column(
type: 'set',
nullable: true,
options: ['values' => ['tag_a', 'tag_b', 'tag_c']]
)]
private ?array $tags = null;
Use array (not ?array) with a default of [] if you want the empty set ('' in MySQL) rather than NULL to be the "no selections" state — the two are semantically different in MySQL.
Form binding
The type round-trips as a PHP array, so bind directly to ChoiceType with multiple: true:
$builder->add('tags', ChoiceType::class, [
'choices' => ['Tag A' => 'tag_a', 'Tag B' => 'tag_b', 'Tag C' => 'tag_c'],
'multiple' => true,
'expanded' => true,
]);
Validate the selection against the DB definition (a Choice/All constraint or a #[Assert\Choice]) — the bundle will happily persist strings MySQL rejects only after the column definition is set, so keep the two in sync.
Querying SET columns
Doctrine's DQL has no native FIND_IN_SET. Two practical options:
$sql = 'SELECT * FROM article WHERE FIND_IN_SET(:tag, tags) > 0';
$rows = $em->getConnection()->fetchAllAssociative($sql, ['tag' => 'tag_a']);
FIND_IN_SET cannot use an index.Ordering by set values
MySQL's SET is a bitmask; ordering by the column sorts by bit position, which matches the order in options.values, not alphabetically. This is often what you want (draft < review < published) but is worth being explicit about in code comments.
Migrations
The diff command generally emits the full SET(...) declaration from the options array, but treat generated migrations as a draft:
options.values should be reviewed in the generated ALTER TABLE ... MODIFY.If diff doesn't pick up a changed value list, hand-write the MODIFY COLUMN in the migration — the metadata-only change doesn't always trigger a diff.
Repositories / lifecycle
Because convertToPHPValue returns an array, remember:
public function addStatus(string $status): self
{
if (!in_array($status, $this->statuses, true)) {
$this->statuses[] = $status;
}
return $this;
}
Doctrine tracks changes to the array property by reference comparison, so mutate via a new array (or explicitly clone) when in doubt; in practice reassignment ($this->statuses = [...$this->statuses, $status]) is the safest pattern.
MySQL/MariaDB only. If the same entity is ever run against PostgreSQL or SQLite (e.g., test suites), the type will fail. Use a compatible type or a platform-conditional mapping; the bundle does not abstract this.
Hard limit of 64 values in a MySQL SET. Plan for a join table if the allowed values are user-tunable or could grow.
Empty set vs NULL. MySQL stores the empty set as '', not SQL NULL. convertToDatabaseValue typically converts [] → '' and null → null. Mixing both as "empty" leads to subtle bugs in IS NULL / = '' filters.
Whitespace and commas in values. A value containing a comma will corrupt the stored representation on round-trip. Never put a , inside an allowed value; validate this at configuration time. Same for leading/trailing whitespace.
Order matters. The value order in options.values defines the bitmask. Reordering values later silently changes numeric/ordering comparisons and any bitmask usage. To change order, add the new value to the end and remove the old one.
Type name collision. The bundle registers a type named set. If another package (or your own code) also registers set, the last registration wins. Grep config/packages/doctrine.yaml under dbal.types for overrides.
Compiler pass ordering. If you need to customize the type (subclass it, add a custom platform), do it in a compiler pass with a higher priority than the bundle's pass, or override the service definition id rather than the type name.
Schema validation noise. doctrine:schema:validate sometimes complains about the column comment/collation on SET columns. If the diff keeps proposing the same MODIFY, it's usually a collation mismatch — set an explicit collation in options (or the field declaration) so both sides agree.
Debugging what Doctrine actually sends. When the round-trip misbehaves, log the raw value in a listener on postLoad / prePersist, or inspect the SQL with doctrine.dbal.logging. Nine times out of ten the issue is an options.values mismatch between the attribute and the actual DB column.
Extension points. The type itself is the natural extension point: subclass it to add validation (reject values not in options.values before hitting the DB) or to normalize the array. Register your subclass under the same set name in doctrine.dbal.types to have it used everywhere.
Maintenance signal. Only one release (2023) and no dependents — pin to the current version and be prepared to fork if you need changes for a newer Doctrine DBAL version; the code is small enough to maintain in-house if necessary.
How can I help you explore Laravel packages today?