stave.dev

Models & Database

Auto-loaded model classes with PDO database connections — no manual require statements, no ORM, just straightforward SQL.

Model Directory

Models live in a model/ directory inside your module. The framework automatically registers an autoloader for this path when the module is instantiated — no require or include needed.

modules/ Article/ Article.php ← Controller model/ ArticleModel.php ← Auto-loaded CategoryModel.php ← Auto-loaded view/

Namespace Convention

Model classes use the module's namespace with \Model appended:

Module Model Namespace
modules/Article/ Zero\Module\Article\Model
modules/UserProfile/ Zero\Module\UserProfile\Model
modules/Admin/submodule/ApiConfig/ Zero\Module\Admin\ApiConfig\Model
How Auto-Loading Works

The framework's autoloader checks if a class namespace contains \Model\. If it does and the module's modelPath is set, it looks for a file named {ClassName}.php in the model directory. The class name must match the filename exactly.

Database Connection

Zero provides a singleton PDO connection via the Database class. Connection details come from app/config/database.ini, which defines constants (HOST, NAME, USER, PASS) that the Database class uses automatically.

use Zero\Core\Database;

// Get the shared PDO connection (lazy-loaded on first call)
$db = Database::getConnection();

// The connection uses these PDO options by default:
// - ERRMODE_EXCEPTION — errors throw exceptions
// - FETCH_ASSOC — fetches return associative arrays
// - EMULATE_PREPARES = false — real prepared statements

The connection is lazy-loaded: it is only created when getConnection() is first called. Subsequent calls return the same PDO instance.

Zero\Core\Model — a typed row gateway

A model can be a plain class holding a PDO, as shown below, and for hand-written joins that is still the right shape. For the common case — one table, fixed columns — core ships Zero\Core\Model, an abstract base that removes the boilerplate.

It is deliberately not an ORM. There is no relationship mapping, no lazy loading and no query builder; it is a row gateway with a writable-column whitelist. Declare the table, the primary key and the columns, and you get typed reads and writes:

<?php
namespace Zero\Module\Article\Model;

class Article extends \Zero\Core\Model {

    protected static string $table   = 'articles';
    protected static string $pk      = 'id';

    /* Writable columns. Anything outside this list is never written and never
       reaches the SQL — the DDL owns id/created_at/updated_at. */
    protected static array $columns  = ['title', 'body', 'author_id'];
}
$article = Article::find(42);              // ?static — null when absent
$article = Article::findBy('title', 'Hello'); // ?static — always ONE row
$drafts  = Article::allBy('status', 'draft'); // array of static

$new = Article::create(['title' => 'New', 'body' => '...']);

$article->title = 'Corrected';            // staged, not written
$article->save();                          // writes only the staged columns
$article->delete();
save() writes only what changed

Columns are staged as you set them, so an instance built from partial data cannot clobber the rest of the row with stale values.

findBy() always returns a single ?static — use allBy() when you want many. That is a deliberate incompatibility with the Entity Framework's where(), which returns null, one entity, or an array depending on how many rows matched.

The generated SQL is plain ANSI — no backtick quoting, no driver-specific insert-id handling — so a model does not pin the application to MySQL. Identifiers are safe unquoted because every column name that reaches SQL comes from the $columns whitelist; the tradeoff is that a column named after a reserved word cannot be used.

Model Template

Models are plain PHP classes that receive a PDO connection in their constructor. There is no base model class to extend — you write the SQL yourself:

<?php
namespace Zero\Module\Article\Model;

class ArticleModel {

    private \PDO $db;

    public function __construct(\PDO $db) {
        $this->db = $db;
    }

    /**
     * Find a single article by ID
     */
    public function find(int $id): ?array {
        $stmt = $this->db->prepare("
            SELECT * FROM articles WHERE id = :id
        ");
        $stmt->execute(['id' => $id]);
        $result = $stmt->fetch(\PDO::FETCH_ASSOC);
        return $result ?: null;
    }

    /**
     * Get all articles, newest first
     */
    public function getAll(int $limit = 50, int $offset = 0): array {
        $stmt = $this->db->prepare("
            SELECT * FROM articles
            ORDER BY created_at DESC
            LIMIT :limit OFFSET :offset
        ");
        $stmt->bindValue('limit', $limit, \PDO::PARAM_INT);
        $stmt->bindValue('offset', $offset, \PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
    }

    /**
     * Create a new article
     */
    public function create(array $data): int {
        $stmt = $this->db->prepare("
            INSERT INTO articles (title, content, user_id)
            VALUES (:title, :content, :user_id)
        ");
        $stmt->execute([
            'title'   => $data['title'],
            'content' => $data['content'],
            'user_id' => $data['user_id'],
        ]);
        return (int) $this->db->lastInsertId();
    }

    /**
     * Update an article
     */
    public function update(int $id, array $data): bool {
        $stmt = $this->db->prepare("
            UPDATE articles
            SET title = :title, content = :content
            WHERE id = :id
        ");
        return $stmt->execute([
            'id'      => $id,
            'title'   => $data['title'],
            'content' => $data['content'],
        ]);
    }

    /**
     * Delete an article
     */
    public function delete(int $id): bool {
        $stmt = $this->db->prepare("DELETE FROM articles WHERE id = :id");
        return $stmt->execute(['id' => $id]);
    }
}

Using Models in Controllers

Instantiate your model in the controller's constructor, passing the database connection:

<?php
namespace Zero\Module;

use Zero\Core\Database;
use Zero\Core\HTTPError;
use Zero\Module\Article\Model\ArticleModel;

class Article extends \Zero\Core\Module {

    private ArticleModel $model;

    public function __construct() {
        parent::__construct();
        $db = Database::getConnection();
        $this->model = new ArticleModel($db);
    }

    public function index() {
        $this->data = ['articles' => $this->model->getAll()];
        $this->respond($this->viewPath . 'index.php');
    }

    public function show($id) {
        $article = $this->model->find($id);
        if (!$article) {
            throw new HTTPError(404, 'Article not found');
        }
        $this->data = ['article' => $article];
        $this->respond($this->viewPath . 'show.php');
    }
}
Always Call parent::__construct()

If you override the constructor, you must call parent::__construct() first. The parent constructor resolves paths, registers autoloaders, and sets up asset symlinks. Without it, $this->viewPath, $this->modelPath, and auto-loading will not work.

Using Models in Views

Since view files have access to $this (the module instance), they can use any data the controller set:

<article>
    <h1><?= htmlspecialchars($this->data['article']['title']) ?></h1>
    <div class="content">
        <?= $this->data['article']['content'] ?>
    </div>
</article>

Multiple Models

A module can have as many model classes as it needs. Each gets its own file in the model/ directory:

modules/ Blog/ Blog.php model/ PostModel.php ← Zero\Module\Blog\Model\PostModel CommentModel.php ← Zero\Module\Blog\Model\CommentModel TagModel.php ← Zero\Module\Blog\Model\TagModel
<?php
namespace Zero\Module;

use Zero\Core\Database;
use Zero\Module\Blog\Model\PostModel;
use Zero\Module\Blog\Model\CommentModel;
use Zero\Module\Blog\Model\TagModel;

class Blog extends \Zero\Core\Module {

    private PostModel $posts;
    private CommentModel $comments;
    private TagModel $tags;

    public function __construct() {
        parent::__construct();
        $db = Database::getConnection();
        $this->posts    = new PostModel($db);
        $this->comments = new CommentModel($db);
        $this->tags     = new TagModel($db);
    }

    public function show($slug) {
        $post = $this->posts->findBySlug($slug);
        $this->data = [
            'post'     => $post,
            'comments' => $this->comments->getForPost($post['id']),
            'tags'     => $this->tags->getForPost($post['id']),
        ];
        $this->respond($this->viewPath . 'show.php');
    }
}

Direct Database Access

For simple one-off queries, you can use Database directly in a controller without creating a model class:

use Zero\Core\Database;

public function stats() {
    $db = Database::getConnection();

    $stmt = $db->query("SELECT COUNT(*) as total FROM articles");
    $count = $stmt->fetch(\PDO::FETCH_ASSOC);

    $this->data = ['total' => $count['total']];
    $this->respond($this->viewPath . 'stats.php');
}
When to Use a Model

If you have more than a couple of queries, or if the same query is needed in multiple endpoints, put it in a model. Models keep your controller focused on request handling and your SQL organized in one place.

Database Configuration

Connection details are defined in app/config/database.ini. The INI file values become PHP constants that Database::getConnection() uses automatically:

HOST = "localhost"
NAME = "myapp"
USER = "dbuser"
PASS = "dbpass"

You can also initialize the connection manually with custom parameters using Database::init():

Database::init([
    'host'    => 'localhost',
    'name'    => 'myapp',
    'user'    => 'dbuser',
    'pass'    => 'dbpass',
    'charset' => 'utf8mb4',
    'sqltype' => 'mysql',
]);

Prepared Statements

With EMULATE_PREPARES set to false by default, all queries use real prepared statements. Always use parameterized queries to prevent SQL injection:

// Named parameters
$stmt = $db->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(['email' => $email]);

// Positional parameters
$stmt = $db->prepare("SELECT * FROM users WHERE id = ? AND active = ?");
$stmt->execute([$id, 1]);

// Use bindValue for LIMIT/OFFSET (requires explicit type)
$stmt = $db->prepare("SELECT * FROM articles LIMIT :limit OFFSET :offset");
$stmt->bindValue('limit', 20, \PDO::PARAM_INT);
$stmt->bindValue('offset', 0, \PDO::PARAM_INT);
$stmt->execute();
Never Interpolate User Input

Always use prepared statements with bound parameters. Never concatenate $_GET, $_POST, or any user input directly into SQL strings. The PDO connection is configured with real prepared statements enabled by default.