Enums in CakePHP – reloaded

If you have been in the Cake ecosystem for a while, you might remember my 13+ year old blog post about how to work with enums in CakePHP the easy way.
I have been using them heavily for all my apps so far.
Tinyint(2) mapping to an alias as well as translatable label text for the template output.

After (Cake)PHP and DBs progressed quite a bit, let’s have a new look on the subject.

PHP 8 enums

With PHP 8 we now have enums and more specifically also backed enums.
The latter are what is mainly useful to us now, as they provide a key value mapping to the DB column values.

CakePHP 5.0.5+ comes with pretty much full support of such enums for your baked code and template forms.

String vs Int

I personally like to use int here, as they provide the same benefits as outlined in the previous post:

  • Easy to adjust the alias without having to modify DB
  • Minimal storage needed (1 byte)
  • As unsigned (recommended) tinyint you have up to 255 enum options. But that said: If you need more than xx, you should think about creating a table and some real relations maybe.

They also would seamlessly upgrade from the former "static enums" of mine.

So how would they look like? Let’s assume we move the field status to this new approach:

namespace App\Model\Enum;

use Cake\Database\Type\EnumLabelInterface;
use Cake\Utility\Inflector;

enum UserStatus: int implements EnumLabelInterface {

    case Inactive = 0;
    case Active = 1;
    ...

    /**
     * @return string
     */
    public function label(): string {
        return Inflector::humanize(Inflector::underscore($this->name));
    }

}

You want to implement the EnumLabelInterface in order to have a nice label text to display, e.g. in dropdown forms for add/edit or as text on index/view.

Setup

You define your table column as tinyint(2) or string and then map it to the EnumType in your Table’s initialize() method.

use App\Model\Enum\UserStatus;
use Cake\Database\Type\EnumType;

$this->getSchema()->setColumnType('status', EnumType::from(UserStatus::class));

Bake

With Bake 3.1.0 you can bake your enums easily.
The convention is to use {EntityName}{FieldName}, e.g.

bin/cake bake enum UserStatus

In case you use tinyint(2) as your column type instead of string, use

bin/cake bake enum UserStatus -i

I would recommend quickly adding the cases you to include, using a simple list or case:value pairs:

bin/cake bake enum UserGender male,female,diverse 

The values will be the same as cases if not specified further.

For int it will use the numeric position as value:

bin/cake bake enum UserStatus inactive,active -i
// same as
bin/cake bake enum UserStatus inactive:0,active:1 -i

Also note that you can fully bin cake bake all now when putting these config into the comment of a DB column, prefixed with [enum]:

    ->addColumn('status', 'tinyinteger', [
        'default' => 0,
        'limit' => 2,
        'null' => false,
        'comment' => '[enum] inactive:0,active:1'
    ])
    ->addColumn('gender', 'string', [
        'default' => null,
        'limit' => 10,
        'null' => true,
        'comment' => '[enum] male,female,diverse'
    ])

It will generate both enum classes for you and auto-map them into the UsersTable class.
On top, it will also prepare the views for it and forms will also work out of the box with it.

Usage

With the enum being a value object of sorts it is now not necessary anymore to provide the options manually.
So the forms for mapped columns can now be changed to

// echo $this->Form->create($user); before somewhere
-echo $this->Form->input('status', ['options' => $user->statuses()]);
+echo $this->Form->input('status');

In cases where your form does not pass in the entity, you still want to define them as options key.
Here you need to either manually iterate over the key and value pairs, or just use the Tools plugin (3.2.0+) EnumOptions trait.

use Tools\Model\Enum\EnumOptionsTrait;

enum UserStatus: int implements EnumLabelInterface {

    use EnumOptionsTrait;

    ...

}

and

echo $this->Form->control('status', ['options' => \App\Model\Enum\UserStatus::options()]);

The same applies if you ever need to narrow down the options (e.g. not display some values as dropdown option), or if you want to resort.
These features were also present in the former way.

Comparing, e.g. in controllers, works with the ->value:

if ($this->request->getData('status') == UserStatus::Active->value)) {...}

For string values you can use === comparison right away always. For int ones you would need to cast the values before doing so, as those integers usually get posted as strings.

Wherever you need to create an enum manually, e.g. in the controller action, you can use

// Now UserStatus enum of that value
$user->status = UserStatus::from((int)$this->request->getData('status'));

Authentication

With TinyAuth 4.0.1 there is now also full support for enums in (User) roles.

A warning, however: Any non scalar values in the session data will make all sessions break each time there is a change to it.
It can be the Enum class here, but also other value objects or alike.
I usually try to keep them out of there, so I don’t have to nuke all sessions after such updates all the time.

Translation

As in the previous post, this is an often needed feature that should be available here if needed.
For our enums here you can wrap the label() return value in __() and should be done.

Make sure to manually add those values to your PO files, as the parser cannot find them when using such variable cases.

Bitmasks

The Tools.Bitmasked behavior is also upgraded to support Enums now:

use App\Model\Enum\CommentStatus;

$this->Comments->addBehavior('Tools.Bitmasked', [
    'bits' => CommentStatus::class, 
    'mappedField' => 'statuses'],
);

By using an Enum for bits it will automatically switch the incoming and outcoming bit values to Enum instances.

You can also manually set the bits using an array, but then you would have to also set enum to the Enum class:

$this->Comments->addBehavior('Tools.Bitmasked', [
    'bits' => CommentStatus::tryFrom(CommentStatus::None->value)::options(), 
    'enum' =>  CommentStatus::class, 
    'mappedField' => 'statuses'],
);

DTOs

With 2.2.0 there is also support for such enums in CakePHP DTOs.

Example:

<dto name="FooBar" immutable="true">
    ...
    <field name="someUnit" type="\App\Model\Enum\MyUnit"/>
    <field name="someStringBacked" type="\App\Model\Enum\MyStringBacked"/>
    <field name="someIntBacked" type="\App\Model\Enum\MyIntBacked"/>
</dto>

Also JSON serialize/unserialize will work fine, for e.g. cross system API calls.

Outlook

With CakePHP 5.1 there are plans to also further provide validation for enums.
See also

There is also an enum library out there that adds quite some syntactic sugar on top.
Check it out, maybe there is something useful to take away for your own enum collections.

Postgres native enum

Postgres has recently added its own native Enum type.
I would advice against using that one for now, as it does not have the outlined benefits and out of the box support from the framework yet.
Instead you can use the name database agnostic approach from above.

Summing it up

PHPStan supports this new enum approach quite well and also likes it more that the methods are now more narrow in return types.

Given that the native enums and their more strict validation on the value(s) can provide pretty much all the features so far mentioned in the static enum approach as well as some neat new things on top (like auto built-in support for forms), it is recommended now to use this approach where you can in your apps.

Also upgrading from my previous approach to this should be quite straight forward.
Enjoy!

Update 2024-03

With CakePHP 5.0.7 also int backed enum validation now works as expected.

0.00 avg. rating (0% score) - 0 votes

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.