Building an ORM to Understand the Ones I Use Every Day
Build the tools you rely on. Not to replace them, but to understand them. Here's how working through an ORM decision turned Doctrine's typed fields and Eloquent's casts from magic I used into choices I could actually read.
Introduction
The advice I come back to most often with junior developers is this: build the things you use every day. Not to replace them, and not because your version will be better. Build them because using a tool teaches you what it does, and rebuilding it teaches you why it was built that way. You use an ORM every day and there is a lot that feels like magic. Building a small one is how to make them feel less opaque.
I got to do exactly that with a microframework that I built a while back. It runs on a small custom data layer on top of mysqli, and that gave me an excuse to answer a question I had taken for granted for years: when the query layer writes a value to the database, how does it know the type? This article walks through the one decision at the center of that, the choices I weighed, the reflection-based answer I landed on, and, honestly, how much it changed the way I read the ORMs I use the rest of the time.
This is less a “here is a pattern to copy” article and more a “here is how I reasoned about it” one. The reflection trick is small. The understanding was the point.
Where the question even comes from
I wanted to surface how to dynamically type the bind params with mysqli in a “set it and forget it” way. When you bind parameters to a prepared statement, mysqli makes you hand it a type string:
$stmt = $mysqli->prepare(
'INSERT INTO my_table (state, some_value) VALUES (?, ?)'
);
// "si": the first value is a string, the second is an integer.
$stmt->bind_param('si', $state, $some_value);
$stmt->execute();
That "si" is the whole question made visible. Something, somewhere, has to know that state is a string and some_value is an integer. Laravel and Doctrine sit on PDO, which does not make you write that string, so the knowledge is still there but the framework hides where it lives. mysqli hands it to you directly, and once it is in your hands you have to decide where that knowledge should come from.
The choices I actually weighed
When I sat down to build the write path, I could see four honest options for where the type information lives:
- Hand-write it at every call site. This is what raw mysqli does. It works, and it is completely unmaintainable past a few columns, because the type string has to be kept in lockstep with the values by hand.
- Declare it in metadata next to the property. Attributes or annotations like
#[Column(type: 'integer')]. This is the Doctrine instinct. It is explicit and powerful, but it is a second declaration of a fact the PHP type already states, and two declarations can disagree. - Keep it loose and cast on the way out. Magic attributes plus a
$castsarray, which is the Eloquent instinct. Flexible, but the model no longer really tells you what it holds, and neither does your IDE. - Read the type the property already declares. The model says
public int $some_value. The type is right there. Reflect it and stop asking the developer to restate it.
I want to be upfront that option four is obviously not something I invented. It is the most interesting thing I learned, and I will come back to it at the end. At the time it simply felt like the option with the fewest ways to drift out of sync, so that is the one I built.
Reading the property instead of restating it
In this data layer a model is a plain class with typed public properties, one per column. Here is an example Movie() model:
enum Genres: string
{
case action = 'ACTION';
case comedy = 'COMEDY';
case drama = 'DRAMA';
}
class Movie extends Model
{
protected string $table = 'movies';
public int $id = 0;
public string $title = '';
public Genres $genre; // a BackedEnum
public float $rating = 0.00;
}
Everything bind_param() wants is already declared. id is an int, title is a string, genre is a string-backed enum, rating is a float. So when the query builder is handed a set of values to write, it reflects each column’s declared type and turns that into a mysqli type character:
public function values(array $value_options = []): void
{
foreach ($value_options as $value) {
// An explicit type is optional; if omitted we reflect the property.
[$column, $value, $type] = array_pad($value, 3, null);
if (empty($type)) {
$ref = new ReflectionProperty($this->model, $column);
$refType = $ref->getType();
$type = $refType instanceof \ReflectionNamedType ? $refType->getName() : null;
}
$this->values_cols[] = $column;
// Enums go across the wire as their backing scalar.
$this->values_vals[] = $value instanceof \BackedEnum ? $value->value : $value;
$this->values_types[] = match ($type) {
'int' => 'i',
'float' => 'd',
default => 's',
};
}
}
The match is the entire answer to the question I started with. It is smaller than I expected it to be, which was its own small lesson. From the calling side, a write just passes values, enum cases included, and never spells out a type:
$movie = new Movie();
$movie->values([
['title', 'The Godfather'],
['genre', Genres::drama],
['rating', 5.0],
]);
$movie->create();
That produces the bind string "ssi" on its own. If I later change any of the properties on the model, the next time the query runs, it will be up to date because there is no second declaration to forget to update.
Getting Backed Enums back from the database
Building the write path surfaced a question the read path made unavoidable: MySQL hands everything back as strings, so a genre column comes back as 'DRAMA', not as Genres::drama. If I wanted the property type to mean something, the type had to be honored in both directions, not just at bind time. This is the kind of thing you only really feel once you have built half of it.
The same reflection that drove binding drives hydration, and it lives in the base Model constructor, which every fetched row passes through:
public function __construct(array $attributes = [])
{
foreach ($attributes as $key => $value) {
if (property_exists($this, $key)) {
$ref = new \ReflectionProperty($this, $key);
$type = $ref->getType();
// A backed-enum column comes out of the DB as a scalar; make it a real case.
if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
$class = $type->getName();
if (is_subclass_of($class, \BackedEnum::class)) {
$this->$key = $value instanceof $class ? $value : $class::from($value);
continue;
}
}
$this->$key = $value;
}
}
}
Now a fetched movie has a genuine Genres on it, and I can write $movie->genre === Genres::drama with autocomplete and no magic strings. The property declaration ended up doing double duty: the query builder reads it to bind on the way in, and the constructor reads it to cast on the way out. Realizing those were the same fact read from two directions was the moment the design clicked for me.
None of is is hard. All of it is invisible until you are the one deciding.
That is the whole return on the exercise. I did not end up with a better ORM than the ones I use every day. I ended up understanding the ones I use every day, because I had stood where their authors stood and made the same small decisions they did.
Conclusion
If you are a newer developer and you take one thing from this, let it be the habit and not the reflection trick. Build the tools you rely on. Build a tiny router, a tiny template engine, a tiny query builder. You will make crude versions of decisions that real framework authors made carefully, and the next time you open their code those decisions will read like sentences instead of magic.
For me the question was as small as “how does the query know this column is an integer,” and the answer was a match statement reading a typed property. But answering it myself is what turned Doctrine’s and Eloquent’s methods from features I used into choices I understood more deeply. That is the trade worth making, and it is available on almost anything you use every day.
✌️ Matt