Skip to content
academia.sh

Lesson 09 / 21

Object–Table Binding

A small model class that keeps the table as a PHP array is built; the lesson measures that the save hook runs on save, that a field outside the allow list is not wired in mass assignment, and that the hook is not wired in a bulk update.

Published Updated

In this lesson

  • Builds a small mechanism that binds a table to a model class
  • Measures that the save hook runs during a save
  • Shows that a field outside the allow list is not wired in mass assignment
  • Reads from real output that a bulk update does not run the save hook
Contents

The form validation passed and the save step ran. But when the save is made through a model, is only a row written while it is saved? The model’s own hooks, accessors and field rules run too; seeing which of them ran is the subject of this lesson.

Object–relational mapping is a mechanism that binds a class to a table: an instance of the class is a row of the table. What this binding produces at the source was measured in the lesson of the same name in M08/K06; in this lesson the question is different: while the model is saved, which part ran in this request. The decision order is the classify() function itself, and this lesson’s outcome is ran; the measurements are compared with not wired.

VD8. The table is a PHP array, so the data layer is nothing more than an array of rows; there is no real database connection. VD9. Field names are ASCII, and id is derived from the length of the table at save time. VD10. For the mass assignment counterpart, a neutral counter field named views is used; the field carries no permission or role. VD11. Slug generation keeps only ASCII letters and digits.

The measurement setup comes unchanged.

<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}

Building the Model

The model base knows three things: which table it writes to, which fields can be filled in bulk, and which hook it will call while saving. A lifecycle hook is a method called before or after an event; here saving is called right before the record is written to the table. An accessor is a method that transforms a field’s value when it is read; here readSummaryShort is read under the name summaryShort. A query scope names a frequently used filter; published() returns only the published rows.

fill() takes only the fields in the allow list. For a field outside the allow list there is neither an error nor a log entry: the field is never placed on the part’s path. The opposing measurement of this lesson rests on this silence.

<?php
// model.php — a small model with a table array, a scope, an accessor and a hook
declare(strict_types=1);
require_once 'ran.php';

final class Data {
    public static array $tables = ['posts' => []];
    public static function reset(): void { self::$tables = ['posts' => []]; }
}

abstract class Model {
    protected static string $table;
    protected static array $fillable = [];
    public array $attributes = [];

    public static function create(array $input): static {
        return (new static())->fill($input)->save();
    }

    public function fill(array $input): static {
        foreach ($input as $field => $value) {
            if (in_array($field, static::$fillable, true)) {
                Ledger::onPath("field:$field");
                Ledger::ran("field:$field");
                $this->attributes[$field] = $value;
            }
        }
        return $this;
    }

    public function save(): static {
        Ledger::onPath('hook:saving');
        $this->saving();
        $this->attributes['id'] = count(Data::$tables[static::$table]) + 1;
        Data::$tables[static::$table][] = $this->attributes;
        return $this;
    }

    protected function saving(): void {}

    public static function bulkUpdate(array $values, array $condition): int {
        $count = 0;
        foreach (Data::$tables[static::$table] as &$row) {
            if (array_intersect_assoc($condition, $row) === $condition) {
                $row = $values + $row;
                $count++;
            }
        }
        return $count;
    }

    public function __get(string $name): mixed {
        $accessor = 'read' . ucfirst($name);
        return method_exists($this, $accessor) ? $this->$accessor() : ($this->attributes[$name] ?? null);
    }
}

final class Post extends Model {
    protected static string $table = 'posts';
    protected static array $fillable = ['title', 'summary', 'published'];

    protected function saving(): void {
        Ledger::ran('hook:saving');
        $title = strtolower((string) $this->attributes['title']);
        $this->attributes['slug'] = trim(preg_replace('/[^a-z0-9]+/', '-', $title), '-');
    }

    protected function readSummaryShort(): string {
        return mb_substr((string) $this->attributes['summary'], 0, 5) . '...';
    }

    public static function published(): array {
        return array_values(array_filter(Data::$tables['posts'], fn($row) => $row['published'] ?? false));
    }
}

function report(array $parts): void {
    foreach ($parts as $part) printf("  %-20s %s\n", $part, classify($part));
}

Seeing the Save Hook Run

The measurement creates two posts: one published, the other not. Expected: the save hook ran, the slug first-post was generated from the title, the scope returns only the published row, and the accessor shortens the summary. All three fields in the allow list ran.

<?php
// observe.php — the save hook, the scope and the accessor
require_once 'model.php';

Data::reset();
Ledger::newRequest();
$post = Post::create(['title' => 'First Post', 'summary' => 'Short summary text', 'published' => true]);
Post::create(['title' => 'Draft', 'summary' => 'Not ready yet', 'published' => false]);
echo "slug={$post->slug}\n";
echo "summaryShort={$post->summaryShort}\n";
echo "published=" . count(Post::published()) . " / total=" . count(Data::$tables['posts']) . "\n";
report(['field:title', 'field:summary', 'field:published', 'hook:saving']);
slug=first-post
summaryShort=Short...
published=1 / total=2
  field:title          ran
  field:summary        ran
  field:published      ran
  hook:saving          ran

The hook ran on its own inside the call that writes the record; the caller did not need to do anything, and the slug was generated on its own. This is both the benefit and the fragility of the hook: the behavior sits in a place the caller cannot see. The scope and the accessor left no separate run record, because they are methods that work by being called, and what is measured is whether a step took part in the request.

The Opposing Measurement: A Field Outside the Allow List

Mass assignment is the bulk transfer of the fields in a request’s body to the model; the allow list is the boundary of this transfer. The measurement adds the views field, which is not in the allow list, to the request. Expected: the views field is not wired, because it was never placed on the path; the value does not enter the record; there is no error.

<?php
// mass.php — a field outside the allow list is not wired in mass assignment
require_once 'model.php';

Data::reset();
Ledger::newRequest();
$post = Post::create(['title' => 'Second', 'summary' => 'Text', 'published' => true, 'views' => 999]);
echo "views in record: " . (array_key_exists('views', Data::$tables['posts'][0]) ? 'present' : 'absent') . "\n";
report(['field:title', 'field:views']);
views in record: absent
  field:title          ran
  field:views          not wired

views was in the request and is absent from the record. The outcome is not wired, not skipped: a part that is placed on the path and not called would be skipped; here the allow list left the field outside the step of placing it on the path. That no error is given means that in mass assignment, silence can be both protective and deceptive. The answer to the question of why the field did not enter is in the allow list, not in the output.

The Bounding Measurement

The hook runs on the save path. But what if there is an update path that bypasses the save path? bulkUpdate() changes the table rows directly, and save() is not called. The measurement first creates a post, then changes its title with bulkUpdate(). Expected: the hook is not wired, and the slug stays at the old title.

<?php
// bulk.php — a bulk update does not run the save hook
require_once 'model.php';

Data::reset();
Post::create(['title' => 'First Post', 'summary' => 'Text', 'published' => true]);
Ledger::newRequest();
$count = Post::bulkUpdate(['title' => 'New Title'], ['id' => 1]);
$row = Data::$tables['posts'][0];
echo "updated=$count\n";
echo "title={$row['title']} slug={$row['slug']}\n";
report(['hook:saving']);
updated=1
title=New Title slug=first-post
  hook:saving          not wired

In the output the hook is not wired: the title changed, the slug did not. The slug is a derived value that depends on the title, and it becomes inconsistent when the hook is bypassed. A hook that is not wired gives no error, so it does not show in the response either. This outcome is dangerous: there is no trace where the behavior is expected. The solution is to pass the change through the save path, or to have the bulk update write the derived field itself; which one is chosen is the caller’s decision.

Summary

  • A model binds a class to a table; the scope, the accessor and the hook are added on top of this binding.
  • This lesson’s outcome is ran: the save hook ran on its own on the save path and generated a slug from the title.
  • A field outside the allow list is not wired in mass assignment: the value did not enter the record and no error was given.
  • In a bulk update that bypasses the save path, the hook is not wired and the derived field stayed old.
  • Silence can be protective or deceptive; why a field did not enter the record is in the allow list, not in the output.

Next Step

The model handled a single table. When the author of a post sits in another table, when does the relationship query run? The next lesson, Relationships, measures relationships and lazy loading, and covers the outcome in which the relationship query is deferred.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close