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.

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.