Database Models and Active Record Traits

Tutorial: Creating Custom Models

Zero CMS leverages PHP traits to provide extremely simple, Eloquent-like Active Record models. Place models inside the core namespace src/Models/ or decoupled module namespaces src/Modules/[ModuleName]/Models/.

Extend the Zero\Interfaces\Model interface, and include IsModel for CRUD and HasSlug for slugs:

<?php
namespace App\Modules\Blog\Models;

use Exception;
use Zero\Core\App;
use Zero\Core\Env;
use Zero\Database\DB;
use Zero\Interfaces\BlockHelperInterface;
use Zero\Interfaces\Controller;
use Zero\Interfaces\Job as JobInterface;
use Zero\Interfaces\Model;
use Zero\Interfaces\SeederInterface;
use Zero\Models\Traits\HasSlug;
use Zero\Models\Traits\IsModel;
use App\Modules\Blog\Models\Post;
use Zero\Modules\Search\Traits\Searchable;
use Zero\Modules\Shop\Jobs\SendOrderReceipt;
use Zero\Support\Emailer;
use Zero\Support\Security;

class Post implements Model {
    use IsModel, HasSlug;
    protected static $tableName = 'blog_posts';
    protected static $fillable = ['site_id', 'title', 'slug', 'content', 'type', 'status'];
    protected static $modelType = 'post';
}

Form Schema Configuration (getConfig())

Define admin form schemas directly inside the model's static getConfig() method to automate admin dashboard listings and edits generation:

public static function getConfig(): array {
    return [
        'id' => ['type' => 'text', 'label' => 'ID', 'editable' => false, 'listDisplay' => false],
        'title' => ['type' => 'text', 'label' => 'Post Title', 'editable' => true, 'required' => true, 'listDisplay' => true, 'searchable' => true],
        'slug' => ['type' => 'text', 'label' => 'Slug', 'editable' => false, 'listDisplay' => true]
    ];
}

Cascading Soft-Deletes (CascadesDeletes)

A model that owns child records (e.g. a Site owning Pages, or a Product owning Variants) can declare a static $cascadeDeletes property mapping each child model class to the foreign key column that links back to the parent. When an admin soft-deletes the parent record, every matching child row is soft-deleted (or force-deleted, for a permanent purge) automatically.

Before the delete confirmation is shown, the admin listing calls a cascade-check API endpoint (ModelApiController) which counts affected child rows per model and surfaces them in the confirmation dialog, so an admin never soft-deletes a Site without knowing it will also take down its Pages, Media, and Orders.

Declaring Cascade Relationships

Declaring Cascade Relationships php
class Site implements Model {
    use IsModel, CascadesDeletes;

    protected static $tableName = 'sites';

    protected static $cascadeDeletes = [
        \Zero\Models\Page::class => 'site_id',
        \Zero\Models\Media::class => 'site_id',
        \Zero\Models\User::class => 'site_id',
    ];
}

Defense-in-Depth: Validating SQL Identifiers

Table and column names can't be bound as PDO placeholders the way values can, so wherever a child table/foreign-key pair from $cascadeDeletes is interpolated directly into raw SQL, ModelApiController first validates both strings against ^[a-zA-Z0-9_]+$ and silently skips the count if either fails. The values are always hardcoded class metadata rather than request input, but the check is kept anyway as a defense-in-depth guard against a future refactor accidentally routing untrusted input through the same code path.