fervo/enum-bundle
fervo/enum-bundle provides practical utilities for working with PHP enums in Laravel applications, including helpers for enum-backed model attributes, validation, and common conversions so enum values stay type-safe and easy to use throughout your codebase.
1. Install and register
composer require fervo/enum-bundle
There is almost certainly no Symfony Flex recipe, so register it manually:
// config/bundles.php
return [
// ...
Fervo\EnumBundle\FervoEnumBundle::class => ['all' => true],
];
2. Define your first enum
The bundle centers on a base class you extend with class constants:
namespace App\Enum;
use Fervo\EnumBundle\Enum\Enum;
class OrderStatus extends Enum
{
public const PENDING = 'pending';
public const PAID = 'paid';
public const SHIPPED = 'shipped';
public const CANCELLED = 'cancelled';
}
3. First use case: a form field backed by the enum
use App\Enum\OrderStatus;
use Fervo\EnumBundle\Form\Type\EnumType;
$builder->add('status', EnumType::class, [
'enum_class' => OrderStatus::class,
]);
Where to look first
Enum class — that's where getValues(), getChoices(), isValid() and friends live; everything else builds on it.Before you commit: this is an 11-star package with a last release in October 2022 and zero dependents. If you are on PHP 8.1+, native backed enums plus Symfony 6.2's EnumType/BackedEnumType and Doctrine's enumType cover nearly all of this. Adopt this bundle when you're maintaining a Symfony 4/5 or PHP 7.4 codebase and want the helper API, not as a greenfield dependency.
Enum definition conventions
Use string constants for anything crossing a database or API boundary, and treat the constant as the single source of truth in entities, queries, and forms:
$qb->andWhere('o.status = :status')
->setParameter('status', OrderStatus::PAID); // never type 'paid' inline
Derive form choices instead of duplicating them:
'choices' => OrderStatus::getChoices(),
If labels need to be human-readable, override with choice_label rather than baking display strings into the constants.
Doctrine mapping
Create a thin type per enum and register it globally:
namespace App\Doctrine;
use App\Enum\OrderStatus;
use Fervo\EnumBundle\Doctrine\Types\EnumType;
class OrderStatusType extends EnumType
{
public function getName(): string
{
return 'order_status';
}
protected function getEnumClass(): string
{
return OrderStatus::class;
}
}
# config/packages/doctrine.yaml
doctrine:
dbal:
types:
order_status: App\Doctrine\OrderStatusType
Then #[ORM\Column(type: 'order_status')]. One type per enum, type names prefixed — Doctrine type names share one global registry.
Validation at the edges
Put the constraint on DTOs and command objects, not just entities, so invalid values fail before they reach the domain:
use Fervo\EnumBundle\Validator\Constraints\Enum;
class UpdateOrderRequest
{
#[Enum(enumClass: OrderStatus::class)]
public string $status;
}
This is the pattern that pays for itself: controllers and message handlers can assume the value is valid, and the constraint produces a standard Symfony violation rather than a 500 from the Doctrine type.
Hydration from requests and APIs
Convert strings to enum-backed values at the boundary and return 400 on failure:
if (!OrderStatus::isValid($input)) {
throw new BadRequestHttpException('Unknown status');
}
For JSON, serialize the scalar value (the constant), not the enum object — it keeps your payloads stable if you later migrate to native enums.
Twig rendering
Expose the choices once as a Twig global or via the bundle's extension, then render labels from a single map. Avoid {% if status == 'paid' %} chains; compare against constant('App\\Enum\\OrderStatus::PAID').
The native-enum collision is the big one. If you're on PHP 8.1+, Symfony ships Symfony\Component\Form\Extension\Core\Type\EnumType. Importing both is a fatal name clash — alias one (use ... as FervoEnumType;) or, better, don't mix the two philosophies in one codebase.
Missing type registration is the #1 runtime error. If you see Unknown column type "order_status" requested, the type wasn't registered for the environment (check doctrine.dbal.types and that the bundle is enabled in that env). It surfaces at schema-tool time and at query time, which makes it look like two different bugs.
Removing an enum constant is a data migration. Rows containing a retired value will throw when the Doctrine type converts them back. Before dropping a constant, run a data migration or make the type lenient (return null / a fallback for unknown values). Test this on a production-sized dataset — it's the kind of thing that only breaks on real data.
Values are not type-safe. Constants are string/int at the PHP level, so passing 'pendng' compiles fine. Add static analysis (@psalm-type or a PHPStan enum rule) or lean on the validator constraint. If you later move to native backed enums, ::from() gives you that safety for free — a good reason to keep the conversion boundary thin now.
Case sensitivity and strict comparison. Comparisons are strict string comparisons; 'Paid' is not 'paid'. Normalize input in the DTO (strtolower) before validating, or you'll chase phantom validation failures.
getChoices() uses the value as both key and label. That's fine for a select, but if you need translated labels, register a custom choice_label callback rather than adding display text to constants (which then leaks into the database).
Extension points worth knowing: subclass the Doctrine type for custom null handling or default values; add a Form type extension to attach constraints automatically; write a Twig function that wraps getChoices() so templates never touch the enum class name directly.
Debugging checklist: var_dump(OrderStatus::getValues()) to confirm the constant set, OrderStatus::isValid($x) to confirm boundary logic, and bin/console doctrine:schema:validate after any type change. In tests, assert with assertSame(OrderStatus::PAID, $entity->getStatus()) — comparing loosely against a raw string hides conversion bugs.
Have an exit plan. Given the low activity, treat this as a thin adapter layer: keep enum classes in App\Enum, keep Doctrine types in App\Doctrine, and avoid calling bundle-specific statics from deep inside your domain. Swapping to native enums later then becomes a mechanical change to two directories instead of a rewrite.
How can I help you explore Laravel packages today?