Static Enums or “Semihardcoded Attributes”

There are many cases where an additional model + table + relation would be total overhead. Like those little "status", "level", "type", "color", "category" attributes.
Often those attributes are implemented as "enums" in SQL – but cake doesn’t support them natively. And it should not IMO. You might also want to read this 😉

If there are only a few values to choose from and if they don’t change very often, you might want to consider the following approach.
I use it a hundred times. Its very efficient and easily expandable.

Why not using hardcoded integer values like 0, 1, 2 etc?
The answer is simple: It is usually really bad style to do so ("magic numbers" should be avoided).
What if you want to update your code a few weeks later – you probably don’t even know anymore what 1 or 2 stand for. Constants like TYPE_ACTIVE TYPE_PENDING, though, are way more descriptive.

Let’s get started

a) Add tinyint(2) unsigned field called for example "status" (singular)
"Tinyint(2 / 3) unsigned" covers 0…127 / 0…255 – which should always be enough for enums. if you need more, you SHOULD make an extra relation as real table. Do not use tinyint(1) as cake interprets this as a toggle field, which we don’t want!

b) Put this in your AppModel (or better use the Tools Plugin and all its auto-build in functionality right away):

/**
 * @param int|array|null $value
 * @param array $options
 * @param string $default
 * @return string|array
 */
public static function enum($value, $options, $default = '') {
    if ($value !== null) {
        if (array_key_exists($value, $options)) {
            return $options[$value];
        }
        return $default;
    }
    return $options;
}

c) Put something like this in any model where you want to use enums:

/**
 * @param int|array|null $value
 * @return string|array
 */
 public static function statuses($value = null) {
    $options = array(
        self::STATUS_NEW => __('statusNew',true),
        self::STATUS_UNREAD => __('statusUnread',true),
        self::STATUS_READ => __('statusRead',true),
        self::STATUS_ANSWERED => __('statusAnswered',true),
        self::STATUS_DELETED => __('statusDeleted',true),
    );
    return parent::enum($value, $options);
}

const STATUS_NEW = 0; # causes sound, then marks itself as "unread"
const STATUS_UNREAD = 1;
const STATUS_READ = 2;
const STATUS_ANSWERED = 4;
const STATUS_DELETED = 5;
// add more - order them as you like

d) Use them in your controller logic, model functions, view forms, …:

//view form
...
echo $this->Form->input('status', array('options' => Notification::statuses()));
//controller action
...
if ($this->data['Notification']['status'] == Notification::STATUS_READ)) {...}
//controller logic on find
...
$options = array('conditions' => array('Notification.user_id' => $uid, 'Notification.status <=' => Notification::STATUS_UNREAD));
$notifications = $this->Notification->find('all', $options);
//view index/view
...
<?php echo h($notification['Notification']['title']);?>
<?php echo Notification::statuses($notification['Notification']['status']); // returns translated text ?>

=> NOTE: example with "statuses", could also be priorities, gender, types, categories, … etc
anything that is not often changed or extended

That’s it!

Conclusion: fast and easy to extend in the future if neccessary.
It also saves a lot of overhead by using (tiny)ints instead of strings
AND it does not need any additional table joins! which makes it even more performant.

Further advantages

  • can be used from anywhere (model, controller, view, behavior, component, …)
  • reorder them by just changing the order of the array values in the model
  • auto-translated right away (i18n without any translation tables – very fast)
  • create multiple static functions for different views ("optionsForAdmins", "optionsForUsers" etc). the overhead is minimal.

What you cant do:

  • sort by the value of the keys (only by keys): small, medium, low => no sorting by their name ASC/DESC, only by key ASC/DESC

Final tips:
If you use them in an index view many times repeatedly if would make sense to write them into an array before using them. Otherwise, the translation will be transformed every time. caching would also work!

Validation/emptyFields: use ’empty’ attribute in form helper options array for default "blank" with either 'empty' => array(0 => 'xyz') to allow 0 values or 'empty' => 'xyz' to require one value (combined with validation rule "numeric").

Do not try to set default values in your view. It’s better and cleaner to leverage the controller for this. See "Default Values" chapter.

Combination with LazyLoading

This approach gets more complicated in combination with "Lazy Loading" of Models (if even implemented). In this case, the related models are not imported until actually needed. So if you use constants from your model before this happens, you get a fatal error! You would need to App::import() all models which you need for static enum access. This can be done in the controller actions and adds not more than 1-2 lines of code.

I am working on a PHP5.3+ ONLY solution right now which uses the brand new __callStatic() method and works well with LazyLoading. Stay tuned…

For Cake2.0, all you have to do is make sure that the classes are defined before you access their constants:

App::uses('MyModel', 'PluginName.Model');

You can do that right before you use it or define it globally in your Controller class for example.

UPDATE August 2011 – Enums in baking templates

Include your enums in your templates for "cake bake" in order to have them in your views out of the box.
All you need to do:

a) Add the pluralized static method of the enum field to the model (e.g. status => statuses):

    /**
     * @static
     */
    public static function statuses($value = null) {
        $array = array(
            self::STATUS_INACTIVE => __('Inactive', true),
            self::STATUS_ACTIVE => __('Active', true),
        );	
        return parent::enum($value, $array);
    }

    const STATUS_INACTIVE = 0;
    const STATUS_ACTIVE = 1;

b) Adjust your bake templates

foreach ($fields as $field) {
    if (strpos($action, 'add') !== false && $field === $primaryKey) {
        ...
    } elseif ($schema[$field]['type'] === 'integer' && method_exists($modelClass, $enumMethod = lcfirst(Inflector::camelize(Inflector::pluralize($field))))) {
        echo "\t\techo \$this->Form->input('{$field}', array('options' => " . Inflector::camelize($modelClass) . "::" . $enumMethod . "()));\n";
    } ...

this is the code for the form.ctp template

... 
} elseif ($schema[$field]['type'] === 'integer' && method_exists($modelClass, $enumMethod = lcfirst(Inflector::camelize(Inflector::pluralize($field))))) {
    echo "\t\t<td>\n\t\t\t<?php echo " . $modelClass . "::" . $enumMethod . "(\${$singularVar}['{$modelClass}']['{$field}']); ?>\n\t\t</td>\n";
} 
...

and this for index.ctp (view.ctp works similar)

So if the bake script finds the static method "statuses" in the model it will then present the dropdown in the forms as well as the translated (+i18n) value in the views (index/view).

UPDATE October 2011 – Regroup and Reorder

With a little trick it is now possible to return only a small subset (and in different order) for specific form elements. This is the improved enum method for your AppModel:

/**
 * @param string $value or array $keys or NULL for complete array result
 * @param array $options (actual data)
 * @return mixed string/array
 */
public static function enum($value, $options, $default = null) {
    if ($value !== null && !is_array($value)) {
        if (array_key_exists($value, $options)) {
            return $options[$value];
        }
        return $default;
    } elseif ($value !== null) {
        $newOptions = array();
        foreach ($value as $v) {
            $newOptions[$v] = $options[$v];
        }
        return $newOptions;
    }
    return $options;
}

The current version can now also be found in my Tools plugin.

Let’s say, we have our status array from above. But for users we dont want them to be able to set the record to "deleted". And let’s say we are in the Message Model:

'options' => Message::statuses(array(Message::STATUS_NEW, Message::STATUS_UNREAD, Message::STATUS_READ, Message::STATUS_ANSWERED))

We pass this on as options for the FormHelper and the deleted status is not available. No extra methods or configuration required. The order in which the keys are passed will decide the order of the translated enum values.

Update 2012-02-26 – Cake2 and Bitmaps

If you are looking for combining several booleans into a single database field check out my Bitmasked Behavior.

Other approaches

Mine is not the only solution to the problem (although I think mine is in most enum cases the best^^). Others have their own approach on this:

  • You can use the ArrayDatasource of cakephp/datasources (although this is a little bit more verbose and not quite as flexible)
  • Miles uses the EnumerableBehavior
  • Cake3.x might actually someday natively recognize enums (there is an open PR for it) – I will then update this post.

Update for CakePHP 3.x

With CakePHP 3 you have both Table and Entity classes instead of just Model classes. Thus you need to move those enum methods to the Entity classes.
In 3.x it will be even easier to use it, since you can directly access any entity statically via

App\Model\Entity\MyEntityName::enumMethod()

But – and that’s the really neat thing now – since those entities are passed down to the views for forms and alike, you can also directly access them there:

echo $this->Form->input('status', array('options' => $user->statuses()));

So all in all the 2.x code here should be fairly easy to upgrade for 3.x.

Update 2024-02

With Cake4 and PHP 8+ you can now also start thinking about upgrading this to actual enum types.
The transition should be super smooth as the ones described here will natively update themselves to "backed int" enums, so mapping those ints to a string value the same way, just within a native PHP object type.

12 Comments

  1. Just added the HowTo for baking enums right away. Those enums then work out of the box.

  2. Hey,

    i don’t get the use of the "enum" function in the AppModel.

    What’s its use.

    Greetings
    func0der

  3. It should be pretty obvious from the examples.
    The function is the main functionality behind the enums.

    PS: Added tips for it to work in Cake2.0

  4. If you keep the intended default value in the list of constants set to 0. Add the following code to your form.ctp Bake template and you get a nice way of selecting a default value from a list in the resulting add.ctp/admin_add.ctp form whilst leaving the user selected value in the edit.ctp/admin_edit.ctp forms.

    /* ...from the Model */
      const PERIOD_FORTHNIGHTLY = 0;
      const PERIOD_WEEKLY = 1;
      const PERIOD_MONTHLY = 2;
    
    /* ...for the Bake form.ctp */
    $default = (strpos($action, 'add') !== false) ? 0 : null ;
    
    echo "\t\t\t\t\tForm->input('{$field}', array('options'=>".Inflector::camelize($modelClass)."::".$enumMethod."(),'default' => $default));?>\n";
  5. Further to the last comment + a bug fix. Cake docs recommend using ‘default’ = &gt; 0 to specify no default value, which may interfere with constants set as 0. So instead I went for constants starting from 1 and not 0m aking sure 1 was the default value for the set of values. So

        public static function generatePeriods($value = null) {
          $array = array(
              self::PERIOD_WEEKLY => __('Weekly', true),
              self::PERIOD_FORTHNIGHTLY => __('Fortnightly', true),
              self::PERIOD_MONTHLY => __('Monthly', true)
          );    
          return parent::enum($value, $array);
      }
      const PERIOD_FORTHNIGHTLY = 1;
      const PERIOD_WEEKLY = 2;
      const PERIOD_MONTHLY = 3;
    
    /*...and then in form.ctp bake template */
    
    $default = (strpos($action, 'add') !== false) ? 1 : 0 ;
    
    echo "\t\t\t\t\tForm->input('{$field}', array('options'=>".Inflector::camelize($modelClass)."::".$enumMethod."(),'default' => ".$default."));?>\n";
  6. Hi Mark,

    since Cake 3 doesn’t have something like an AppEntity. Traits are recommended for shared functionality.
    But if you code the static enum() method into a trait, you have to change

    return parent::enum($value, $options);

    into

    return self::enum($value, $options);

    in the statuses() ore other related method in the entities.

    Thanks for this helpful piece of code!

  7. This works fantastically, but since the enum() function only returns one selection, isn’t this actually for a ‘set’, not an ‘enum’?

  8. If you do not pass arguments it will return the whole set, if you pass one of the values it returns this value.

  9. Thanks, i am using cakephp 2.8, so it works great but i need some help in finding usin "LIKE" comare example:

    'conditions' => array('Model.type LIKE'=>'%'.$search.'%')....

    Where i used Static Enums to save "type" .
    thank you

  10. You never use LIKE with integers, you use exact matching:

    'field' => $value

    And especially never with arrays of integers, there you use IN

    'field IN' => [...]
  11. Just a small correction to the general confusion about column sizes –
    You stated: "Tinyint(2 / 3) unsigned" covers 0…127 / 0…255
    which is not completely true. TINYINT(2 / 3) UNSIGNED will always be able to accommodate any integer value between 0-255, i.e., 1 byte. the 2 or 3 in the brackets is the display width which doesn’t affect the maximum possible value that can be stored. The display width is normally useful if you use zerofill attribute of the column, i.e it will display 01 (for width 2) and 001(for width 3) if the stored value is 1. if the stored value is 111, then 111 will be displayed for width 2 and 3 both.
    Read in detail on MySQL docs at https://dev.mysql.com/doc/refman/8.0/en/numeric-type-attributes.html
    Hope that clears up things a bit.
    By the way, I love your cakephp blogs and plugins (and use several of them in my projects). Keep up the good work. Cheers!

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.