Stand With Ukraine. Stop Putin. Stop War.

You've probably heard of UUIDs as a way to have unique identifiers for objects. But have you heard of ULIDs?

I sure didn't until @PhilSturgeon tweeted about it, but since then I've taken a closer look and just finished implementing them into an xPDO project that was using UUIDs until now.

The basic premise is that, just like UUIDs, you can create the ULID without needing to know what the last one was (which is different from the standard primary auto-increment ID mostly used in xPDO projects). They're guaranteed to be unique, at least up to massive number of generations per millisecond. If the project needs to scale across multiple servers, or if you use it for request logging, that's a big plus. 

Comparing ULID vs UUID is more subjective, but I like that you get simpler and shorter identifiers. While a UUID would look something like 05c337c3-d2b3-4a50-a8b1-90e4fae23cfc, a ULID looks like 01edksqtx9cfzzt1y9sm57h3yq. It's still random gibberish, but it's cleaner and ready to be used in URLs.

If I understand it correctly, the first part of the ULID is based on the timestamp and will actually sort (roughly) by the time the ULID was generated, which is also a useful feat and may help with insert performance in databases.

To incorporate this into an xPDO project, you'll need to replace your xPDOSimpleObject usage with a custom base object and implement some logic related to the primary key.

For this example, I'm using robinvdvleuten/php-ulid. If you're following along, install that into your project with composer and make sure your autoloader has been loaded.

In your xPDO XML Schema, define a base object that all your other objects will extend from. Make note that this should extend xPDOObject (and not xPDOSimpleObject), and define the field to hold the primary key.

The examples in this article are based on xPDO 3, for use with xPDO 2 remove the namespaces (and perhaps add a custom prefix for your project to avoid conflicts) and it ought to work just the same.

<?xml version="1.0" encoding="UTF-8"?>
<model package="YourNamespace\Model\" baseClass="YourNamespace\Model\BaseObject" platform="mysql" defaultEngine="InnoDB" version="1.1">
    <object class="YourNamespace\Model\BaseObject" extends="xPDO\Om\xPDOObject" inherit="single">
        <field key="ulid" dbtype="varchar" precision="52" phptype="string" null="false" />

        <index alias="ulid" name="ulid" primary="true" unique="true" type="BTREE">
            <column key="ulid" length="" collation="A" null="false" />
        </index>
    </object>
    
    
    <object class="SomeObject" table="some_object">
        ...
    </object>
</model>

As a ULID is encoded as a 26 character string, you can get away with lowering the precision to 26 but I like to have a little bit of padding in case things change down the road. 

Because I'm defining the baseClass in the model, I've skipped providing the extends attribute on the object. 

Also note that we're defining the index to be primary and unique.

Now build the model classes using a build script or for xPDO 3 the parse-schema command:

vendor/bin/xpdo parse-schema mysql path/to/your/project.mysql.schema.xml src/ -v --update=1 --psr4=YourNamespace

With the model files generated, edit your BaseObject class which should be in src/Model/BaseObject.php or model/yourproject/baseobject.class.php for xPDO 2.

What we're going to do is overriding the save() method to set the primary key for new objects with a freshly generated ULID.

(Again, note this is an xPDO 3 example. If using xPDO 2, remove namespaces and it should work the same.)

<?php
namespace YourNamespace\Model;

use Ulid\Ulid;
use xPDO\Om\xPDOObject;
use xPDO\xPDO;

class BaseObject extends xPDOObject
{
    public function save($cacheFlag = null)
    {
        if ($this->isNew()) {
            $this->set($this->getPK(), Ulid::generate(true));
        }
        return parent::save($cacheFlag);
    }
}

Pretty simple, huh? In this example I'm passing true to the generate method because I prefer the key to be lowercase. 

Now when you use the xPDO APIs to create new objects, it'll automatically add the ULID. And this will also work with your foreign keys and relations - just make sure to use a varchar field that can hold the ULID rather than an int like you may be used to with autoincrementing keys.

When retrieving objects note that you should use the array syntax on $xpdo->getObject to specify the key. xPDO might just be smart enough to handle it as we've defined the primary key, but for security reasons you should never pass arbitrary user data as a string into the second parameter of getObject

<?php

$ulid = (string)$_GET['ulid']; // or whereever you're getting this from, like a routing component
$object = $xpdo->getObject(\YourNamespace\Model\SomeObject::class, ['ulid' => $ulid]);
if ($object) {
    var_dump($object->toArray());
}
else {
    echo 'Doesn\'t exist';
}

Enjoy.

Shawn Maddock

Your schema definition says version 1.1, but the syntax looks like version 3.0, specifically the non-escaped namespaces.

Mark Hamstra

Pretty sure xPDO 3 does not introduce a new _schema_ version ;) That's either 1.0 (for xPDO < 2.2) or 1.1 last I checked.

But yes, as mentioned various times throughout the article, the examples are indeed for xPDO 3 and you will indeed need to take out namespaces to use it on xPDO 2.

Mark Hamstra

I stand corrected! Will need to look at what effect that has compared to the 1.1 syntax. Can definitely confirm xPDO 3 and namespaces work fine with the 1.1 schema ;)

Shawn Maddock

The main advantage I see of using some form of UID would be multi-table indexing, similar to https://stackoverflow.com/questions/2002985/mysql-conditional-foreign-key-constraints#2003042 I implemented this in xPDO 3 last week... also using ULIDs would let me remove the MySQL triggers making sure new objects don’t are pulling the auto-increment int from the parent table.

Comments are closed :(

While I would prefer to keep comments open indefinitely, the amount of spam that old articles attract is becoming a strain to keep up with and I can't always answer questions about ancient blog postings. If you have valuable feedback or important questions, please feel free to get in touch.