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.
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 |
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');
}
}
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:
<?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');
}
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();
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.