PHP backed enums are used throughout the framework for anything that represents a fixed set of values — entity status codes, type fields, queue names, user groups. The template ships App\Abstraction\Traits\BasicEnumMethodsTrait as a standard interface for entity-related enums; copy or extend it freely when starting a new project. The conventions described on this page are project conventions, not framework requirements — in your own projects, structure enums however makes sense to you.
The trait defines a standard interface that all entity enums in the project implement:
trait BasicEnumMethodsTrait
{
/**
* Return an instance of the enum by its ID.
*/
abstract public static function getById( int $id ): self;
/**
* Return an ID of the enum.
*/
abstract public function getId(): int;
/**
* Return a name of the enum.
*/
abstract public function getName(): string;
/**
* Return a name code of the enum to save in the database, use in the URL, file name etc.
*/
abstract public function getNameCode(): string;
/**
* Return a list of all enums.
*
* @return array<static>
*/
final public function getList(): array
{
return ( new ReflectionClass( static::class ) )
->getConstants( ReflectionClassConstant::IS_PUBLIC );
}
}
| Method | Purpose |
|---|---|
getById( int $id ) |
Look up an enum case by its database integer ID |
getId(): int |
Return the integer ID stored in the database |
getName(): string |
Return the human-readable translated name |
getNameCode(): string |
Return the machine-friendly string used in URLs, filenames, logs |
getList(): array |
Return all cases as an array keyed by case name (the value is the case instance) |
getList() returns an array shaped [ 'CaseName' => CaseName, ... ] — the keys are the case names as declared in the enum, and each value is the enum case instance itself. This shape comes straight from PHP's ReflectionClass::getConstants(IS_PUBLIC), which exposes each case both ways. To get the integer IDs out:
$ids = array_map( fn( $case ) => $case->getId(), StatusEnum::getList() );
The trait enforces a consistent API across all entity enums — any code that works with one enum works with any other because they all implement the same four methods.
Note: this section is an illustrative reference implementation — no
UserGroupEnumis shipped in the current template. Copy and adapt it to your project.
UserGroupEnum is the clearest example of the full pattern in practice:
// App/Enums/UserGroupEnum.php
namespace App\Enums;
use App\Abstraction\Traits\BasicEnumMethodsTrait;
use App\Entity\UserGroup;
use ValueError;
enum UserGroupEnum: string
{
use BasicEnumMethodsTrait;
case BANNED = 'banned';
case ADMINISTRATOR = 'administrator';
case MODERATOR = 'moderator';
case USER = 'user';
public static function getById( int $id ): self
{
return match ( $id ) {
-1 => self::BANNED,
1 => self::ADMINISTRATOR,
3 => self::MODERATOR,
6 => self::USER,
default => throw new ValueError( "Invalid UserGroupEnum id: $id" ),
};
}
public function getId(): int
{
return match ( $this ) {
self::BANNED => -1,
self::ADMINISTRATOR => 1,
self::MODERATOR => 3,
self::USER => 6,
};
}
public function getName(): string
{
return _t(
match ( $this ) {
self::BANNED => 'Banned',
self::ADMINISTRATOR => 'Administrator',
self::MODERATOR => 'Moderator',
self::USER => 'User',
}
);
}
public function getNameCode(): string
{
return match ( $this ) {
self::BANNED => 'banned',
self::ADMINISTRATOR => 'administrator',
self::MODERATOR => 'moderator',
self::USER => 'user',
};
}
public function getEntity(): UserGroup
{
return UserGroup::find( $this->getId() );
}
}
A few things worth noting:
getName() passes the string through _t() — the displayed name is translated to the current locale. getNameCode() does not — it returns a stable machine-readable string that doesn't change with locale.
getEntity() is an optional addition not part of BasicEnumMethodsTrait but useful for enums that directly correspond to a lookup table entity.
getById() uses match with an explicit default arm that throws ValueError on an unknown ID. This is intentional. An unknown ID means something is wrong at the data layer and should be loud, not silently return null:
public static function getById( int $id ): self
{
return match ( $id ) {
-1 => self::Banned,
1 => self::Administrator,
// ...
default => throw new ValueError( "Invalid UserGroupEnum id: $id" ),
};
}
Use ValueError (PHP's standard for invalid int|string arguments to internal functions) rather than letting the default UnhandledMatchError bubble up — the explicit message names the enum and the bad ID, which makes log triage faster than a bare UnhandledMatchError.
These are project conventions for any project built with this framework. They exist to keep enum files easy to find and their purpose immediately obvious from the file path alone.
Entity enums mirror the entity manager (EM) structure of the project — one subdirectory per EM, not per entity. There is no enforced naming convention for EM names; Main is the conventional default but apps can name EMs however they like. Enums for an entity live under the EM that the entity itself belongs to:
App/
├── Entity/
│ ├── Main/ # the conventional default EM
│ │ └── User.php
│ └── OtherEm/ # any "other" EM follows the same rule
│ └── Foo.php
│
└── Enums/
├── Entity/ # enums that map to entity columns
│ ├── Main/
│ │ └── UserGroupEnum.php
│ └── OtherEm/
│ └── FooStatusCodeEnum.php
├── Support/ # non-entity enum — UI filter, not a Doctrine column
│ └── AssigneeFilterEnum.php
└── Amqp/ # non-entity enum — RabbitMQ queues
└── QueueEnum.php
Note: this directory tree is illustrative. The template currently ships only
App/Enums/Amqp/QueueEnum.php(see Non-entity enums) — theApp/Enums/Entity/...subdirectories and the entity enums above are conventions to apply when you start adding entity-backed enums to your project.
The rule: an enum for entity X in EM Y lives at App\Enums\Entity\Y\{Entity}{Col}X.php. Use one subdirectory per EM; place all enums for entities in that EM inside it. Non-entity enums (UI filters, queues, feature flags, configuration options, or shared vocabularies consumed by other services) do not belong under App\Enums\Entity\ — give them a domain folder like Support/, Amqp/, Log/, Notification/, etc.
Why per-EM and not per-entity: keeping every enum that talks to a given database inside the same folder makes it easy to audit "everything that touches the
securityEM" at a glance, and matches howdoctrine.yamlactually maps entity namespaces —App\Entity\Security↔App\Enums\Entity\Security. Putting enums under per-entity subfolders spreads them across the tree and hides the DB grouping.
For non-entity enums (queues, feature flags, configuration options, JSON-discriminator vocabularies, cross-service contracts), use a descriptive subdirectory under App/Enums/ — Amqp/, Config/, Feature/, Support/, etc. — or place them directly under App/Enums\ when no domain folder fits.
The pattern is {Entity}{Col}Enum — entity first, column second. Reading FooStatusCodeEnum as "the enum for the status_code column on the Foo entity" makes the purpose immediate. This matches the project's App\Enums\Entity\{EM}\{Entity}{Col} rule and keeps related enums grouped alphabetically within an EM folder (all Foo… enums together, all User… enums together, etc.).
Entity: App/Entity/OtherEm/Foo.php
↓
Enum: App/Enums/Entity/OtherEm/FooInvoiceTypeEnum.php # {Entity}{Col}Enum
Enum: App/Enums/Entity/OtherEm/FooStatusCodeEnum.php
{Entity} — the entity class name, exactly as written (without the Entity suffix if there is one). Singular.
{Col} — what the enum represents on that entity. Keep it specific — InvoiceType, StatusCode, PaymentMethod, not just Type or Status.
Enum — always the suffix.
Dropped-stem special case. When the entity stem would be repeated inside the column stem, drop the entity stem. Example: User + user_group column → UserGroupEnum, not UserUserGroupEnum.
Enum cases use PascalCase — each word capitalised, no underscores:
enum ProcedureStatusCodeEnum: string
{
case FirstNotice = 'first_notice';
case SecondNotice = 'second_notice';
case FinalNotice = 'final_notice';
case Resolved = 'resolved';
case WrittenOff = 'written_off';
}
The case name is what you read in PHP code. The backed string value is what goes into the database or URL — use snake_case for the value, PascalCase for the name.
Not SECOND_NOTICE, not second_notice — SecondNotice. The reasoning: PHP enums aren't constants, they're proper types. ProcedureStatusCodeEnum::SecondNotice reads naturally as a value of that type, whereas ProcedureStatusCodeEnum::SECOND_NOTICE reads like a constant from a 2010 PHP codebase.
Note: this section is an illustrative reference implementation —
ProcedureStatusCodeEnum,ProcedureInvoiceTypeEnum, and theApp/Enums/Entity/Invoices/directory are not shipped in the current template. The pattern is what to copy; the names are placeholders.
Building on the conventions above — an enum for a hypothetical Procedure entity in App/Entity/Invoices/:
// App/Enums/Entity/Invoices/ProcedureStatusCodeEnum.php
namespace App\Enums\Entity\Invoices;
use App\Abstraction\Traits\BasicEnumMethodsTrait;
use ValueError;
enum ProcedureStatusCodeEnum: string
{
use BasicEnumMethodsTrait;
case FirstNotice = 'first_notice';
case SecondNotice = 'second_notice';
case FinalNotice = 'final_notice';
case Resolved = 'resolved';
case WrittenOff = 'written_off';
public static function getById( int $id ): self
{
return match ( $id ) {
1 => self::FirstNotice,
2 => self::SecondNotice,
3 => self::FinalNotice,
4 => self::Resolved,
5 => self::WrittenOff,
default => throw new ValueError( "Invalid ProcedureStatusCodeEnum id: $id" ),
};
}
public function getId(): int
{
return match ( $this ) {
self::FirstNotice => 1,
self::SecondNotice => 2,
self::FinalNotice => 3,
self::Resolved => 4,
self::WrittenOff => 5,
};
}
public function getName(): string
{
return _t(
match ( $this ) {
self::FirstNotice => 'First Notice',
self::SecondNotice => 'Second Notice',
self::FinalNotice => 'Final Notice',
self::Resolved => 'Resolved',
self::WrittenOff => 'Written Off',
}
);
}
public function getNameCode(): string
{
return $this->value;
}
public function isActive(): bool
{
return match ( $this ) {
self::FirstNotice,
self::SecondNotice,
self::FinalNotice => true,
default => false,
};
}
}
And the corresponding invoice type enum for the same entity:
// App/Enums/Entity/Invoices/ProcedureInvoiceTypeEnum.php
namespace App\Enums\Entity\Invoices;
use App\Abstraction\Traits\BasicEnumMethodsTrait;
use ValueError;
enum ProcedureInvoiceTypeEnum: string
{
use BasicEnumMethodsTrait;
case Standard = 'standard';
case Proforma = 'proforma';
case CreditNote = 'credit_note';
public static function getById( int $id ): self
{
return match ( $id ) {
1 => self::Standard,
2 => self::Proforma,
3 => self::CreditNote,
default => throw new ValueError( "Invalid ProcedureInvoiceTypeEnum id: $id" ),
};
}
public function getId(): int
{
return match ( $this ) {
self::Standard => 1,
self::Proforma => 2,
self::CreditNote => 3,
};
}
public function getName(): string
{
return _t(
match ( $this ) {
self::Standard => 'Standard Invoice',
self::Proforma => 'Proforma Invoice',
self::CreditNote => 'Credit Note',
}
);
}
public function getNameCode(): string
{
return $this->value;
}
}
Enums integrate with entities via the getById() / getId() pattern — the database stores the integer ID, the application works with enum cases:
// App/Entity/Invoices/Procedure.php
#[Assert\Choice( choices: [ 1, 2, 3, 4, 5 ] )]
#[ORM\Column( type: 'integer', options: [ 'default' => 1 ] )]
protected int $statusCode = 1;
public function getStatusCode(): ProcedureStatusCodeEnum
{
return ProcedureStatusCodeEnum::getById( $this->statusCode );
}
public function setStatusCode( ProcedureStatusCodeEnum $statusCode ): self
{
$this->statusCode = $statusCode->getId();
return $this;
}
The getter returns the enum case, the setter accepts an enum case and stores the integer. The database column stores only the integer:
// Reading
$procedure->getStatusCode() // ProcedureStatusCodeEnum::SecondNotice
$procedure->getStatusCode()->getName() // 'Second Notice' (translated)
$procedure->getStatusCode()->getNameCode() // 'second_notice'
$procedure->getStatusCode()->getId() // 2
$procedure->getStatusCode()->isActive() // true
// Writing
$procedure->setStatusCode( ProcedureStatusCodeEnum::Resolved );
// Comparison
if ( $procedure->getStatusCode() === ProcedureStatusCodeEnum::WrittenOff ) {
// handle written off case
}
Not all enums map to entity fields. QueueEnum is the shipped one — it defines available RabbitMQ queues and provides access to the message bus:
// App/Enums/Amqp/QueueEnum.php
namespace App\Enums\Amqp;
use PHP_SF\System\Database\RabbitMQ;
use PHP_SF\System\Database\RabbitMQConsumer;
enum QueueEnum: string
{
case DEFAULT = 'default_queue';
// todo: add more queues, if needed
public function getMessageBus(): RabbitMQ
{
return RabbitMQ::getInstance($this);
}
public function consume(callable $callback): void
{
(new RabbitMQConsumer())
->consume($this, $callback);
}
}
Note: the backed string for a queue case must match a transport name declared in
config/packages/messenger.yaml. A mismatch (e.g.DEFAULT = 'default_queue'against a transport nameddefault) throwsInvalidRabbitMQConfigurationExceptionat the firstdispatch(). When adding a new case, add a matching transport inmessenger.yamlin the same change.
QueueEnum doesn't implement BasicEnumMethodsTrait — it doesn't need getId(), getName(), or getNameCode() because it's not representing a database field value. Only add the trait where it makes sense.
Consuming a queue is not a method on the enum — instantiate the consumer and pass the queue case: ( new RabbitMQConsumer() )->consume( QueueEnum::DEFAULT, $callback ).
BasicEnumMethodsTrait::getList() returns all enum cases keyed by case name — useful for populating select dropdowns or validation:
// All cases as an array, keyed by case name
ProcedureStatusCodeEnum::getList();
// [
// 'FirstNotice' => ProcedureStatusCodeEnum::FirstNotice,
// 'SecondNotice' => ProcedureStatusCodeEnum::SecondNotice,
// ...
// ]
// Populate a select element in a view
<?php foreach ( ProcedureStatusCodeEnum::getList() as $case ) : ?>
<option value="<?= $case->getId() ?>">
<?= $case->getName() ?>
</option>
<?php endforeach ?>
// Dynamic validation against all valid IDs
$validIds = array_map(
fn( ProcedureStatusCodeEnum $case ) => $case->getId(),
ProcedureStatusCodeEnum::getList()
);
Inconsistent ID assignment — getId() values must be stable. If you change the integer returned by getId() for an existing case, every row in the database with that stored integer now maps to the wrong case. Treat enum IDs as immutable once they're in production data.
Adding a case without a database migration — if your validation uses Assert\Choice with a hardcoded list of valid IDs and you add a new enum case, the validator will reject the new ID until the validation list is updated. Keep choices in sync with the enum's getId() returns, or derive them dynamically:
// Fragile — must be updated manually
#[Assert\Choice( choices: [ 1, 2, 3, 4, 5 ] )]
// Better — derive from the enum
// (requires a const or static method on the enum)
Using the backed string value as the database ID — the backed string value (case SecondNotice = 'second_notice') is for getNameCode() and URL usage. The integer from getId() is what gets stored in the database column. Don't conflate the two — they serve different purposes.
Skipping getNameCode() when value would do — for string-backed enums where the backed value is already the name code, getNameCode() can just return $this->value. That's fine and is what ProcedureStatusCodeEnum does above. Don't add unnecessary logic just to feel like you're implementing the interface properly.