The PHP framework behind SitioYa

Koshkil documentation

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.