Data and security
Models and database
Defining a model
Each table has a class that extends TModel. The file goes in src/Models/<name>.php (or plugins/<plugin>/src/Models/) and the class is named TModel + the name in CamelCase.
<?php
// src/Models/productos.php
Koshkil::Uses('sys.db.model');
Koshkil::UsesModel('categorias');
class TModelProductos extends TModel {
protected $tableName = 'tbl_productos';
public $primaryKeyColumn = 'prd_codigo';
// Only these fields are assigned by create(), fill() and update()
protected $fillable = ['cat_codigo', 'prd_nombre', 'prd_precio', 'prd_destacado', 'prd_alta', 'prd_datos'];
protected $dates = ['prd_alta']; // shown as dd/mm/yyyy and stored as yyyy-mm-dd
protected $json = ['prd_datos']; // decoded to an array when read
protected function setupTableStructure() {
$this->table->id('prd_codigo')
->varchar('prd_nombre', 120, false, '')
->decimal('prd_precio', 10, 2, false, 0)
->enum('prd_destacado', ['0', '1'], false, '0')
->datetime('prd_alta')
->text('prd_datos')
->belongsTo('categorias') // adds cat_codigo + foreign key
->addKey('idx_prd_nombre', 'prd_nombre');
}
// Custom method: used as $producto->precioConIva()
public function precioConIva() {
return round($this->prd_precio * 1.21, 2);
}
}
To use it:
Koshkil::UsesModel('productos'); // includes the file and creates the TMProductos alias
Koshkil::UsesModel('noticias.noticias'); // a plugin's model -> TMNoticias
UsesModel() looks in src/Models/ first and then in core/models/, so a project can replace a core model.
Model properties
| Property | Purpose |
|---|---|
$tableName |
Table name. |
$primaryKeyColumn |
Primary key (public). |
$fillable |
Mass-assignable fields. Others are ignored when saving. |
$dates |
Date fields: read as dd/mm/yyyy [hh:mm:ss] and stored in SQL format. |
$json |
Fields decoded from JSON when read. |
$skipEncoding |
Fields not passed through htmlentities() when saved (for example HTML from an editor). |
$encodeQuotes |
Fields where quotes are encoded too. |
$readOnly |
true disables create, update and delete. |
$behaviors |
Behaviors to load (see below). |
$dependantModels |
Models whose records depend on this one (used by canDelete() and cascadeDelete()). |
$alias |
Default table alias in queries. |
Encoding on save
By default, text is saved through htmlentities() (keeping <, >, & and quotes). If a field stores HTML or data that must not be transformed, add it to $skipEncoding.
The schema
setupTableStructure() describes the table through $this->table, a chainable builder.
Columns
| Method | Signature |
|---|---|
id |
id($column) — auto-increment integer and primary key |
integer |
integer($column, $length, $null = true, $default = null) |
tinyint |
tinyint($column, $length, $null = true, $default = null) |
decimal |
decimal($column, $length, $decimals, $null = true, $default = null) |
float |
float($column, $length, $decimals, $null = true, $default = null) |
double |
double($column, $null = true, $default = null) |
char / varchar |
varchar($column, $length, $null = true, $default = null, $collate = null) |
text / longtext |
text($column, $null = true, $default = null) |
enum |
enum($column, $values, $null = true, $default = null) |
date / datetime |
datetime($column, $null = true, $default = null) |
blob / longblob |
blob($column) |
All of them accept a final $options argument (for example ['extra' => 'AUTO_INCREMENT']).
Keys, table options and initial data
$this->table
->addKey('idx_email', 'usr_email')
->addKey('idx_composite', ['cat_codigo', 'prd_nombre'])
->engine('InnoDB')
->charset('utf8mb4')
->collate('utf8mb4_unicode_ci')
->initialRecords([
['cat_nombre' => 'General'],
['cat_nombre' => 'Offers'],
]);
initialRecords() inserts those records when the table is created.
Automatic sync
With Database.AutoUpdateSchema = true, the first time a model is instantiated (and whenever its file changes), Manager::checkTable() compares the definition with the real table:
- if the table does not exist, it creates it with its keys and initial data;
- it adds new columns and alters the ones whose type or length changed;
- it creates missing keys and foreign keys (if the referenced table does not exist yet, the key is kept pending until it does).
Columns not in the model are dropped
The sync runs DROP on every table column that is not declared in setupTableStructure(). Before pointing a model at an existing table, declare all of its columns. In production you can disable AutoUpdateSchema and apply changes with migrations.
The resulting structure is stored in the models cache together with the model file's modification time; as long as the file does not change, the database is not queried again.
Create, read, update and delete
// Create: returns the created record (with its primary key)
$producto = TMProductos::create([
'prd_nombre' => 'Notebook 14"',
'prd_precio' => 850000,
'prd_alta' => date('d/m/Y H:i:s'),
]);
// Read
$producto = TMProductos::find(15); // by primary key, or null
$producto->prd_nombre; // as a property
$producto['prd_nombre']; // or as an array
$producto->record(); // all fields as an array
// Update
$producto->fill(['prd_precio' => 799000])->update();
$producto->prd_destacado = '1';
$producto->update();
// Delete
$producto->delete(); // honors readOnly and behaviors
if ($categoria->canDelete()) { // does it have dependent records?
$categoria->cascadeDelete(); // deletes the dependants too
}
// Bulk operations (a single statement, no events)
TMProductos::updateAll(['prd_destacado' => '0'], ['cat_codigo' => 3]);
TMProductos::deleteAll(['cat_codigo' => [7, 8]]); // an array becomes IN (…)
create() also accepts a second argument with extra fields allowed for that call only.
Queries
Static calls to methods the model doesn't have are forwarded to its TQueryBuilder, so queries start right on the class:
$list = TMProductos::where('prd_destacado', '1')
->where('prd_precio', '<', 100000)
->order('prd_nombre', 'ASC')
->take(12)
->get(); // TCollection of TMProductos
foreach ($list as $producto) {
echo $producto->prd_nombre;
}
Query builder reference
| Method | Example |
|---|---|
select($fields) |
select('prd_codigo, prd_nombre') |
distinct() |
|
alias($a) |
TMProductos::alias('p') |
where(…) |
where('field', 'value'), where('field', '>', 5), where('field', ['a','b']) (IN), where('field', 'in', [1,2]), where('raw SQL'), where(['field', '>', 5]) (condition as an array) |
orWhere(…) |
orWhere([['usr_email', $x], ['usr_user', $x]]) → (… OR …) |
whereNull($field) |
|
rawWhere(…) |
unprocessed condition (raw SQL) |
join($table, $condition, $type = 'inner') |
join(TMCategorias::alias('c'), 'c.cat_codigo = p.cat_codigo', 'left') |
joinUsing($table, $column) |
joinUsing(TMUsuariosRoles::alias('ur'), 'usr_codigo') |
group($fields) / having(…) / orHaving(…) |
|
order($field, $dir) |
field and direction are validated; single-argument order('SQL expression') is raw |
take($n) / offset($n) |
limit and offset |
pageSize($n)->page($p) |
pagination |
get() |
runs the query and returns a TCollection |
first() |
first record or null |
find($id) |
by primary key |
getAsArray() |
arrays instead of models |
lists('field') |
array of values; with several fields, array of rows |
each($callback) |
iterates the results |
dataTable() |
response in jQuery DataTables format |
compile() |
returns the SQL without running it |
debug(true) |
logs the SQL |
noEvents() |
does not fire model events |
Collections
get() returns a TCollection: iterate it with foreach, access it by index, and use count(), first(), last(), map(), each(), slice() and getKeys(). The totalRecords property holds the total number of records ignoring the limit (useful for pagination) and compiledSQL the executed query.
$page = TMProductos::order('prd_nombre')->pageSize(20)->page(2)->get();
$pages = ceil($page->totalRecords / 20);
Query safety
insert and update use prepared statements, and the query builder automatically escapes every value given to where(), orWhere(), having(), orHaving(), whereEncoding(), find(), lists(), updateAll() and deleteAll(). Pass user data as is, without escaping it first:
TMUsuarios::where('usr_email', $this->Request->email)->first(); // escaped by where()
TMProductos::where('prd_nombre', 'like', "%{$this->Request->q}%")->get();
TMProductos::where('cat_codigo', [$a, $b, $c])->get(); // every item is escaped
Don't escape twice
Passing an already escaped value (Koshkil::escape(), $this->esc()) to where() escapes it again, and searches containing quotes or backslashes stop matching.
The builder also validates what it cannot escape:
| Part | Protection |
|---|---|
Operator (where('field', $op, $value)) |
Only =, <>, !=, <, >, <=, >=, <=>, LIKE, NOT LIKE, IN, NOT IN, IS, IS NOT, REGEXP, NOT REGEXP and RLIKE are accepted. Anything else throws TQueryBuilderException. |
order($field, $dir) |
The field must be a column name (column or table.column) and the direction ASC or DESC; otherwise it throws TQueryBuilderException. This makes it safe to sort by a column picked in the interface. |
take() / offset() |
Cast to integers. |
These are still raw SQL and must never receive unhandled user data: single-argument where('raw SQL'), rawWhere(), join() conditions, select(), group() and single-argument order(). If you need a value inside those fragments, escape it with Koshkil::escapeString() (without quotes) or Koshkil::quote() (with quotes), or cast it with intval():
$q = Koshkil::escapeString($this->Request->q);
$qb->where("MATCH(prd_nombre) AGAINST ('{$q}')");
$qb->where("FIND_IN_SET(" . intval($this->Request->grupo) . ", prd_grupos)");
$qb->where('p.cat_codigo = ' . Koshkil::quote($this->Request->categoria));
Inside an API endpoint, $this->esc($value) is the same as Koshkil::escapeString().
Relations
They are declared in setupTableStructure() and generate virtual methods on the model.
// In TModelProductos: each product belongs to a category
$this->table->belongsTo('categorias');
// creates the cat_codigo column (integer, the categories primary key), its foreign
// key and the $producto->categoria() method (the model name in singular)
// In TModelCategorias: a category has many products
$this->table->hasMany('productos', ['foreignKey' => 'cat_codigo', 'virtualMethodName' => 'productos', 'orderBy' => ['prd_nombre' => 'ASC']]);
// One-to-one with extra conditions
$this->table->hasOne('galeria', [
'foreignKey' => 'gal_relacionado',
'columnName' => 'usr_codigo',
'virtualMethodName' => 'imagenPerfil',
'where' => [['gal_grupo', 'usuarios'], ['gal_uso', 'profile']],
]);
| Option | Relation | Meaning |
|---|---|---|
column_name |
belongsTo |
Local column (by default, named like the related model's primary key). Useful to belong to the same model twice. |
columnName |
hasMany, hasOne |
Local column that is compared (by default, the model's own primary key). |
foreignKey |
hasMany, hasOne |
Column of the related model that points to this one. Best always set it. |
virtualMethodName |
all | Name of the generated method. |
where |
hasMany, hasOne |
Extra conditions. |
orderBy |
hasMany |
Result order: ['column' => 'ASC', …]. |
$categoria = TMCategorias::find(3);
foreach ($categoria->productos() as $producto) { … }
$photo = $usuario->imagenPerfil();
Events
A method named <prefix>_event_<event> is registered automatically as a handler. The prefix is free (it groups handlers, for example by trait name).
| Event | When | Receives / returns |
|---|---|---|
setupTableStructure |
after the schema is defined | |
precreate |
before inserting | receives and returns the model to insert |
create |
after inserting | receives and returns the created record |
update |
after updating | |
delete |
after deleting | |
getrecord |
when each item of a collection is built |
class TModelProductos extends TModel {
public function productos_event_precreate($record) {
$record->prd_alta = date('d/m/Y H:i:s');
return $record;
}
}
TMProductos::noEvents()->… disables events for one query.
Bundled traits
| Trait | File | Adds |
|---|---|---|
THierarchyTrait |
sys.db.traits.hierarchy |
Parent-child trees ($parentField, $textField): roots(), children(), getParent(), getRoot(), createAsChild(), makeRoot(), makeChildOf(). |
TOrderableTrait |
sys.db.traits.orderable |
Manual ordering ($orderField, default idx_order): indexUp(), indexDown(), setOrderIndex(), nextIndex(). |
TOwnableTrait |
sys.db.traits.ownable |
Assigns the current user (usr_codigo) on create: belongsToUser(), strictlyBelongsToUser(), isMine(), owner(). |
TTranslatableTrait |
sys.db.traits.translatable |
Records translated per language (idi_codigo, $translationParent): translation($language), getTranslations(), mainLanguage(). |
TMultimediaTrait |
sys.db.traits.multimedia |
Files attached through the gallery: imagenes(), documentos(), videos(), sonidos(), multimedia(). |
Koshkil::uses('sys.db.traits.hierarchy');
Koshkil::uses('sys.db.traits.ownable');
class TModelCategorias extends TModel {
use THierarchyTrait, TOwnableTrait;
protected function setupTableStructure() {
$this->parentField = 'cat_parent'; // the column is added by the trait
$this->textField = 'cat_nombre';
$this->table->id('cat_codigo')->varchar('cat_nombre', 45, false, '');
}
}
Behaviors
A behavior adds methods to several models without inheritance. It is a <Name>Behavior class extending Behavior, in src/Models/Behaviors/<Name>.php. In a plugin, the class is also prefixed with the plugin name (<Plugin><Name>Behavior) and loaded as '<plugin>.<name>'. Its methods are called as if they belonged to the model, which it receives in $this->record. If it defines beforeDelete($model), that runs before every delete().
<?php
// src/Models/Behaviors/Publicable.php
Koshkil::uses('sys.db.behavior');
class PublicableBehavior extends Behavior {
public function publicar() {
$this->record->fill(['pub_estado' => '1'])->update();
}
}
class TModelArticulos extends TModel {
protected $behaviors = ['publicable'];
}
TMArticulos::find(4)->publicar();
Migrations
src/Migrations/ holds versioned scripts with up() and down() methods for changes the declarative schema doesn't cover (data, tables without a model, delicate production changes). The core does not include a runner: run them from your own script.
<?php
// src/Migrations/20260425120000_create_personal_negocios_table.php
class Migration20260425120000 {
public function up() {
Koshkil::$db->execute("CREATE TABLE IF NOT EXISTS `tbl_personal_negocios` (…)");
}
public function down() {
Koshkil::$db->execute("DROP TABLE IF EXISTS `tbl_personal_negocios`");
}
}
Direct database access
Koshkil::$db (or Koshkil::getDatabase()) is the active driver:
$db = Koshkil::getDatabase();
$db->execute("UPDATE tbl_productos SET prd_precio = prd_precio * 1.1");
$row = $db->getRow("SELECT COUNT(*) AS total FROM tbl_productos");
$res = $db->getRecords("SELECT * FROM tbl_productos WHERE cat_codigo = 3"); // ['data' => […], 'records' => N]
$db->performTransaction([$sql1, $sql2]);
$db->escape($text); // with PDO the value comes back already quoted
Koshkil::escapeString($text); // same with any driver, without quotes
Koshkil::quote($text); // 'text'
$db->lastInsertId();
The driver is chosen with Database.master.Driver: MySQLi, PostgreSQL, SQLite, SQLServer or Oracle. Koshkil tries the PDO version first and falls back to the native one if PDO is not available.