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
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.