The Model Layer
Models are where the database is allowed to be touched. They are also where a row stops being a row and becomes something the rest of the application can use.
Overview
A model belongs to a controller, is found by the same name, and is constructed for it. It exposes a small, predictable set of methods, and the controller that owns it does not know what a query looks like.
The reason for the boundary is not purity. It is that the same data is needed by the panel, the client area, the API and the scheduled command line. A query written into a page can only ever serve that page.
Structure
coremio/models/{surface}/{name}.php, matching the controller name exactly. Loaded by path during construction, not by the autoloader.
\WISECP\Models\{Surface}\{Name}, then {Name}Model, then a bare Model. No match leaves you with the plain base class and no methods of your own.
$this->db, resolved on first read through __get(). A page that never queries never opens a connection.
Reference
The Base Class
public string $pfx; // table prefix, for raw SQL fragments
public ?Database $connection = null;
public static ?self $init = null; // the last model constructed - WDB reads its connection
public function __get($name); // 'db' returns the Database; anything else null
protected function lang_routes(string $langTable, int $ownerId): array; // ['en' => 'slug', 'tr' => 'slug']
public function menu_list($type, $lang, $list = [], $parent = 0);
public function link_detector($link): string;
The connection behind $this->db is held in a function-static, so every model in the process shares one Database object. WDB is a static facade over that same object. Two entry points, one builder and one unfinished query state.
The Usual Methods
These are conventions rather than an interface, and following them means a reader who has seen one model has seen them all. The signatures below are the shape the newer models use.
public function list(bool $rCount = false, array $filters = [], array $orders = [], int $start = 0, int $end = -1): array|int;
public function get(int $id): array|false;
public function add(array $data = []): int;
public function update(int $id = 0, array $data = []): bool;
public function delete(int $id): bool;
true returns the row count as an int and skips the columns, ordering and paging. The table component calls the same method twice, once each way.
?? so every key is optional. word is the shared one, the free-text search. The rest are the model's own: services accepts status, user_id, product_id, server_id, cycle, duedate plus duedate_op, and so on.
['id' => 'DESC']: bare column name to direction. The model prefixes the table alias itself, so do not pass one. An empty array falls back to the model's default order.
limit(). $end = -1 means no limit at all, which is the default, so ask for a page explicitly.
get() returns false, never an empty array, so the caller can tell "no such record" from "a record with nothing in it".
The Query Builder
Every builder method returns the connection, so calls chain and build() or save() closes the chain. The static facade forwards to the same object with one difference: its where() stops at four arguments, the instance takes a fifth.
public static function select($arg = '*');
public static function from($arg = '');
public static function join($type, $table, $where); // $type: "LEFT", "INNER", ...
public static function where($column, $mark = '', $value = '', $logical = '');
public static function group_by($arg = '');
public static function order_by($arg);
public static function limit($arg1, $arg2 = null);
public static function build($isthis = false); // runs a read, returns falsy on failure
public static function fetch_assoc($statement = false); // all rows
public static function getAssoc($statement = false); // one row
public static function getObject($statement = false);
public static function rowCounter($statement = false); // affected/returned row count
public static function insert($table, $data); // row count; the id comes from lastID()
public static function update($table = '', $data = []); // chain where(), then save()
public static function delete($arg = '', $arg2 = '');
public static function save($isthis = false);
public static function lastID();
// The instance carries one extra argument on where():
public function where($column = '', $mark = '', $value = '', $logical = '', $filter = '?'): self;
"knowledgebase AS t". The installation's table prefix is added for you.
AND behind, which is added for you.
build() ? fetch : fallback rather than an unconditional fetch.
limit(1) so the statement fetches what you intend to read.
save() commits it. An update without a condition rewrites the table.
Translated Rows
Records shown to people keep their translations in a companion table named after the record's own. It holds one row per language, keyed by owner_id and lang. A read joins it for the active language and falls back to the record's own value. A partially translated installation still shows a value. The same table carries the per-language slug, which is what lang_routes() returns for locale switchers and alternate links.
Example
namespace WISECP\Models\Admin;
use Language;
use Models;
class Widgets extends Models
{
public function list(bool $rCount = false, array $filters = [], array $orders = [], int $start = 0, int $end = -1): array|int
{
$lang = Language::selected();
$search = $filters["word"] ?? '';
if (!$orders || empty(array_key_first($orders))) $orders = ['id' => 'DESC'];
$stmt = $this->db->select($rCount ? "t.id" : "t.id, COALESCE(tl.name, t.name) AS name, t.status")
->from("widgets AS t")
->join("LEFT", "widgets_lang AS tl", "tl.owner_id = t.id AND tl.lang = '" . $lang . "'");
// Fourth argument: the operator that joins THIS condition to the next one.
if ($search) $stmt->where("COALESCE(tl.name, t.name)", "LIKE", "%" . $search . "%", "&&");
$stmt->where("t.status", "!=", "deleted");
if ($rCount) return $stmt->build() ? $stmt->rowCounter() : 0;
$order = array_key_first($orders);
$stmt->order_by("t." . $order . " " . strtoupper($orders[$order]));
if ($end != -1) $stmt->limit($start, $end);
return $stmt->build() ? $stmt->fetch_assoc() : [];
}
public function get(int $id): array|false
{
if (!$id) return false;
return $this->db->select("t.*, COALESCE(tl.name, t.name) AS name")
->from("widgets AS t")
->join("LEFT", "widgets_lang AS tl", "tl.owner_id = t.id AND tl.lang = '" . Language::selected() . "'")
->where("t.id", "=", $id)
->limit(1)
->build() ? $this->db->getAssoc() : false;
}
public function add(array $data = []): int
{
return $this->db->insert("widgets", $data) ? $this->db->lastID() : 0;
}
public function update(int $id = 0, array $data = []): bool
{
return (bool) $this->db->update("widgets", $data)->where("id", "=", $id)->save();
}
}
The caller never sees a query, and the two shapes above are exactly what it branches on.
$total = $this->model->list(true, ['word' => $search]); // int
$rows = $this->model->list(false, ['word' => $search], ['id' => 'DESC'], 0, 25);
$widget = $this->model->get($id);
if ($widget === false) return $this->page_404(); // false means "no such record"
Pitfalls
Conditions take their values as arguments so the driver binds them. A value pasted into the condition string, or into a join clause, is an injection waiting for the first unusual input.
It is not the operator that attaches this condition to the previous one. It attaches it to the next. Writing the operator on the wrong condition of an alternation quietly changes which rows come back, and nothing fails.
A getter that selects everything hands its callers whatever the table happens to contain. That includes columns added later that were never meant to leave the database. If such a value can reach a response, filter it at the boundary where it is written out.
A model answers what is stored. What should happen because of it belongs in a helper. The panel, the client area, the API and the scheduler can all reach it there.
Related Articles
Merci pour votre retour !
Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.