Database#
1. From Arrays to Real Data#
In Chapters 2 and 3, data lived in PHP arrays. Every server restart wiped the slate. That works for learning routing and responses. Real applications need persistence. This chapter covers Tina4's database layer: raw queries, parameterised queries, transactions, schema inspection, helper methods, migrations, and seeding.
Tina4 supports five database engines: SQLite, PostgreSQL, MySQL, Microsoft SQL Server, and Firebird. The API stays identical across all of them. Switch databases by changing one line in .env.
2. Connecting to a Database#
The Default: SQLite#
tina4 init creates an empty data/ directory but does not create a database file. The default .env contains:
TINA4_DEBUG=trueNo explicit TINA4_DATABASE_URL means Tina4 defaults to sqlite:///data/app.db. The SQLite file is created automatically the first time Tina4 opens a database connection (for example, when you run a query, execute a migration, or the framework initialises the database layer at startup with a TINA4_DATABASE_URL configured).
Connection Strings for Other Databases#
Set TINA4_DATABASE_URL in .env:
# SQLite (explicit)TINA4_DATABASE_URL=sqlite:///data/app.dbโ# PostgreSQLTINA4_DATABASE_URL=postgres://localhost:5432/myappโ# MySQLTINA4_DATABASE_URL=mysql://localhost:3306/myappโ# Microsoft SQL ServerTINA4_DATABASE_URL=mssql://localhost:1433/myappโ# FirebirdTINA4_DATABASE_URL=firebird://localhost:3050/path/to/database.fdbFirebird URL Forms#
Firebird is the awkward one: every other engine has a server-side database name (postgres://host:port/dbname), but Firebird wants either an absolute file path on the server, a Windows drive-letter path, or an alias. The classic URI form needs a double slash to keep the leading / of an absolute path through parse_url, which is unintuitive.
Tina4 normalises five equivalent forms. Pick whichever reads best:
# Classic double-slash absolute path -- the URL spec wayTINA4_DATABASE_URL=firebird://SYSDBA:masterkey@localhost:3050//firebird/data/app.fdbโ# Single-slash absolute path -- what most people instinctively typeTINA4_DATABASE_URL=firebird://SYSDBA:masterkey@localhost:3050/firebird/data/app.fdbโ# Windows drive-letter path (also accepts /C%3A/Data/app.fdb)TINA4_DATABASE_URL=firebird://SYSDBA:masterkey@host:3050/C:/Data/app.fdbโ# Firebird alias (single token, no slashes)TINA4_DATABASE_URL=firebird://SYSDBA:masterkey@localhost:3050/employeeFor ops setups that keep the server URL and database location in separate config layers -- or for Windows backslash paths -- set TINA4_DATABASE_FIREBIRD_PATH:
TINA4_DATABASE_FIREBIRD_PATH=C:\firebird\data\app.fdbTINA4_DATABASE_URL=firebird://SYSDBA:masterkey@localhost:3050/ignoredThe env override wins over whatever path is in the URL.
Firebird: Dual-Driver Support#
The Firebird adapter works with either the ibase_* or fbird_* PHP functions. It auto-detects which set is available at connection time. If you have ext-interbase installed, it uses ibase_*. If you have the newer fbird_* functions, it uses those instead. No configuration needed -- install whichever extension is available for your platform and the adapter picks it up.
Separate Credentials#
Keep credentials out of the connection string. Better for production:
TINA4_DATABASE_URL=postgres://localhost:5432/myappTINA4_DATABASE_USERNAME=myuserTINA4_DATABASE_PASSWORD=secretpasswordTina4 merges these at startup. Separate variables take precedence over anything embedded in the URL.
Connection Pooling#
For applications that handle many concurrent requests, enable connection pooling with the pool parameter:
$db = new Database("postgres://localhost/mydb", pool: 5);The pool parameter controls how many database connections are maintained:
pool: 0(the default) -- a single connection is used for all queriespool: N(where N > 0) -- N connections are created and rotated round-robin across queries
Pooled connections are thread-safe. Each query is dispatched to the next available connection in the pool. This eliminates contention when multiple route handlers query the database simultaneously.
Verifying the Connection#
Update .env. Restart. Check:
curl http://localhost:7145/health{ "status": "ok", "version": "3.0.0", "uptime": 3.14, "framework": "tina4-php"}3. Getting the Database Object#
Access the database through the global Tina4\Database class:
<?phpuse Tina4\Router;use Tina4\Database;โRouter::get("/api/test-db", function ($request, $response) { $db = Database::getConnection();โ $result = $db->fetch("SELECT 1 + 1 AS answer");โ return $response->json($result);});curl http://localhost:7145/api/test-db{"answer": 2}Database::getConnection() returns the active connection. Call fetch(), execute(), and fetchOne() on it.
4. Raw Queries#
fetch() -- Get Multiple Rows#
$db = Database::getConnection();โ// Returns an array of associative arrays$products = $db->fetch("SELECT * FROM products WHERE price > 50");Each row is an associative array:
// $products looks like:[ ["id" => 1, "name" => "Keyboard", "price" => 79.99], ["id" => 4, "name" => "Standing Desk", "price" => 549.99]]DatabaseResult#
fetch() returns a DatabaseResult object. It behaves like an array but carries extra metadata about the query.
Properties#
$result = $db->fetch("SELECT * FROM users WHERE active = ?", [1]);โ$result->records; // [["id" => 1, "name" => "Alice"], ["id" => 2, "name" => "Bob"]]$result->columns; // ["id", "name", "email", "active"]$result->count; // total number of matching rows$result->limit; // query limit (if set)$result->offset; // query offset (if set)Iteration#
A DatabaseResult is iterable. Use it directly in foreach:
foreach ($result as $user) { echo $user["name"];}Index Access#
Access rows by index like a regular array:
$firstUser = $result[0];Countable#
count() works on the result:
echo count($result); // number of records in this result setConversion Methods#
$result->toJson(); // JSON string of all records$result->toCsv(); // CSV string with column headers$result->toArray(); // plain array of associative arrays$result->toPaginate(); // ["records" => [...], "count" => 42, "limit" => 10, "offset" => 0]toPaginate() is designed for building paginated API responses. It bundles the records with the total count, limit, and offset in a single array.
Schema Metadata with columnInfo()#
columnInfo() returns detailed metadata about the columns in the result set. The data is lazy-loaded -- it only queries the database schema when you call the method for the first time:
$info = $result->columnInfo();// [// ["name" => "id", "type" => "INTEGER", "size" => null, "decimals" => null, "nullable" => false, "primary_key" => true],// ["name" => "name", "type" => "TEXT", "size" => null, "decimals" => null, "nullable" => false, "primary_key" => false],// ["name" => "email", "type" => "TEXT", "size" => 255, "decimals" => null, "nullable" => true, "primary_key" => false],// ...// ]Each column entry contains:
| Field | Description |
|---|---|
name | Column name |
type | Database type (e.g. INTEGER, TEXT, REAL) |
size | Maximum size (or null if not applicable) |
decimals | Decimal places (or null) |
nullable | Whether the column allows NULL |
primary_key | Whether the column is part of the primary key |
This is useful for building dynamic forms, generating documentation, or validating data before insert.
fetchOne() -- Get a Single Row#
$product = $db->fetchOne("SELECT * FROM products WHERE id = 1");// Returns: ["id" => 1, "name" => "Keyboard", "price" => 79.99]No match returns null.
execute() -- Run a Statement#
For INSERT, UPDATE, DELETE, and DDL -- statements that do not return rows:
$db->execute("INSERT INTO products (name, price) VALUES ('Widget', 9.99)");$db->execute("UPDATE products SET price = 89.99 WHERE id = 1");$db->execute("DELETE FROM products WHERE id = 5");$db->execute("CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT, created_at TEXT)");Full Example: A Simple Query Route#
<?phpuse Tina4\Router;use Tina4\Database;โRouter::get("/api/products", function ($request, $response) { $db = Database::getConnection();โ $products = $db->fetch("SELECT * FROM products ORDER BY name");โ return $response->json([ "products" => $products, "count" => count($products) ]);});curl http://localhost:7145/api/products{ "products": [ {"id": 1, "name": "Keyboard", "price": 79.99, "in_stock": 1}, {"id": 2, "name": "Mouse", "price": 29.99, "in_stock": 1}, {"id": 3, "name": "Monitor", "price": 399.99, "in_stock": 0} ], "count": 3}5. Parameterised Queries#
Never concatenate user input into SQL strings. That is how SQL injection happens:
// NEVER do this:$db->fetch("SELECT * FROM products WHERE name = '" . $userInput . "'");Use parameterised queries instead. Parameters go in the second argument:
$db = Database::getConnection();โ// Named parameters$product = $db->fetchOne( "SELECT * FROM products WHERE id = :id", ["id" => 42]);โ// Positional parameters$products = $db->fetch( "SELECT * FROM products WHERE price BETWEEN ? AND ? ORDER BY price", [10.00, 100.00]);The database driver handles escaping. Your input never touches the SQL string.
A Safe Search Endpoint#
<?phpuse Tina4\Router;use Tina4\Database;โRouter::get("/api/products/search", function ($request, $response) { $db = Database::getConnection();โ $q = $request->params["q"] ?? ""; $maxPrice = (float) ($request->params["max_price"] ?? 99999);โ if (empty($q)) { return $response->json(["error" => "Query parameter 'q' is required"], 400); }โ $products = $db->fetch( "SELECT * FROM products WHERE name LIKE :query AND price <= :maxPrice ORDER BY name", ["query" => "%" . $q . "%", "maxPrice" => $maxPrice] );โ return $response->json([ "query" => $q, "max_price" => $maxPrice, "results" => $products, "count" => count($products) ]);});curl "http://localhost:7145/api/products/search?q=key&max_price=100"{ "query": "key", "max_price": 100, "results": [ {"id": 1, "name": "Wireless Keyboard", "price": 79.99, "in_stock": 1} ], "count": 1}6. Transactions#
Group operations that must succeed or fail as one unit:
<?phpuse Tina4\Router;use Tina4\Database;โRouter::post("/api/orders", function ($request, $response) { $db = Database::getConnection(); $body = $request->body;โ try { $db->startTransaction();โ // Create the order $db->execute( "INSERT INTO orders (customer_id, total, status) VALUES (:customerId, :total, 'pending')", ["customerId" => $body["customer_id"], "total" => $body["total"]] );โ // Get the new order ID $order = $db->fetchOne("SELECT last_insert_rowid() AS id"); $orderId = $order["id"];โ // Create order items foreach ($body["items"] as $item) { $db->execute( "INSERT INTO order_items (order_id, product_id, quantity, price) VALUES (:orderId, :productId, :qty, :price)", [ "orderId" => $orderId, "productId" => $item["product_id"], "qty" => $item["quantity"], "price" => $item["price"] ] );โ // Decrease stock $db->execute( "UPDATE products SET stock = stock - :qty WHERE id = :productId", ["qty" => $item["quantity"], "productId" => $item["product_id"]] ); }โ $db->commit();โ return $response->json(["order_id" => $orderId, "status" => "created"], 201); } catch (\Exception $e) { $db->rollback(); return $response->json(["error" => "Order failed: " . $e->getMessage()], 500); }});Any step fails. rollback() undoes everything. The database never lands in a half-finished state.
Call commit() to save. Forget it and the transaction rolls back when the connection closes.
7. Schema Inspection#
Tina4 exposes methods to inspect your database structure at runtime. The Database object delegates to the underlying adapter, so these work across all five engines.
getTables()#
$db = Database::getConnection();$tables = $db->getTables();Returns an array of table names:
["orders", "order_items", "products", "users"]getColumns()#
$columns = $db->getColumns("products");Returns column definitions:
[ ["name" => "id", "type" => "INTEGER", "nullable" => false, "primary" => true], ["name" => "name", "type" => "TEXT", "nullable" => false, "primary" => false], ["name" => "price", "type" => "REAL", "nullable" => true, "primary" => false], ["name" => "in_stock", "type" => "INTEGER", "nullable" => true, "primary" => false]]tableExists()#
if ($db->tableExists("products")) { // Table exists, safe to query}getDatabaseType()#
$engine = $db->getDatabaseType();// "sqlite", "postgresql", "mysql", "mssql", or "firebird"Returns a lowercase string identifying the active database engine. Use this when you need engine-specific SQL in a multi-database setup.
A Schema Info Endpoint#
Combine these methods to build a schema browser:
<?phpuse Tina4\Router;use Tina4\Database;โRouter::get("/api/schema", function ($request, $response) { $db = Database::getConnection(); $tables = $db->getTables();โ $schema = []; foreach ($tables as $table) { $schema[$table] = $db->getColumns($table); }โ return $response->json([ "engine" => $db->getDatabaseType(), "tables" => $schema ]);});Schema inspection powers admin dashboards, migration generators, and dynamic form builders. The database tells you its own structure -- no guesswork required.
8. Batch Operations with executeMany()#
Insert or update many rows in one call:
$db = Database::getConnection();โ$products = [ ["name" => "Widget A", "price" => 9.99], ["name" => "Widget B", "price" => 14.99], ["name" => "Widget C", "price" => 19.99], ["name" => "Widget D", "price" => 24.99]];โ$db->executeMany( "INSERT INTO products (name, price) VALUES (:name, :price)", $products);executeMany() prepares the statement once and executes it for each item. The SQL is parsed once. Four rows inserted. Much faster than four separate execute() calls.
9. Helper Methods: insert(), update(), delete()#
Shorthand methods for simple operations. No SQL needed.
insert()#
$db = Database::getConnection();โ// Insert a single row$db->insert("products", [ "name" => "Wireless Mouse", "price" => 34.99, "in_stock" => 1]);โ// Insert multiple rows$db->insert("products", [ ["name" => "USB Cable", "price" => 9.99, "in_stock" => 1], ["name" => "HDMI Cable", "price" => 14.99, "in_stock" => 1], ["name" => "DisplayPort Cable", "price" => 19.99, "in_stock" => 0]]);update()#
// Update rows matching a filter$db->update("products", ["price" => 39.99, "in_stock" => 1], "id = :id", ["id" => 7]);Third argument: WHERE clause. Fourth argument: parameters.
delete()#
// Delete rows matching a filter$db->delete("products", "id = :id", ["id" => 7]);These helpers generate SQL for you. Use them for simple CRUD. For joins, subqueries, or aggregations, reach for raw queries.
10. Migrations#
Migrations are versioned SQL scripts that evolve your schema over time. Write migration files. Tina4 applies them in order and tracks what has run. No manual CREATE TABLE calls against production.
File Naming#
Two naming patterns are supported:
- Sequential:
000001_create_products_table.sql - Timestamp:
20260322143000_create_products_table.sql(YYYYMMDDHHMMSS)
Both work. Pick one pattern and stick with it throughout a project. Do not mix them.
Generating a Migration#
tina4 generate migration create_products_tableCreated migration: migrations/20260322143000_create_products_table.sqlCreated migration: migrations/20260322143000_create_products_table.down.sqlThe generator creates two files: the migration itself and a matching .down.sql file for rollback.
Writing the Migration#
Edit migrations/20260322143000_create_products_table.sql:
CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'Uncategorized', price REAL NOT NULL DEFAULT 0.00, in_stock INTEGER NOT NULL DEFAULT 1, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP);Edit migrations/20260322143000_create_products_table.down.sql:
DROP TABLE IF EXISTS products;The .down.sql file is optional but recommended for production projects. It contains the SQL that reverses the migration.
Running Migrations#
tina4 migrate# ortina4php migrateRunning migrations... [APPLIED] 20260322143000_create_products_table.sqlMigrations complete. 1 applied.Each call to tina4 migrate is a batch. All pending migrations applied in a single run share the same batch number. This matters for rollback.
Checking Migration Status#
tina4php migrate:statusMigration Status Applied At--------- ------ ----------20260322143000_create_products_table.sql applied 2026-03-22 14:30:0020260322150000_create_orders_table.sql pending -Shows which migrations have been applied and which are still pending.
Rolling Back#
tina4php migrate:rollbackRolling back last batch... [ROLLED BACK] 20260322150000_create_orders_table.sqlRollback complete. 1 rolled back.Rollback undoes the entire last batch. It finds each migration's .down.sql file and executes it, then removes the tracking records. If you applied three migrations in one tina4 migrate call, rollback undoes all three.
The .down.sql file must share the exact same base name as the migration. For 20260322150000_create_orders_table.sql, the down file is 20260322150000_create_orders_table.down.sql.
Tracking Table#
Tina4 creates a tina4_migration table to track applied migrations. The table has four columns:
| Column | Type | Description |
|---|---|---|
id | integer | Auto-increment primary key |
migration | VARCHAR(255) | The migration filename |
batch | integer | Batch number (increments each tina4 migrate run) |
applied_at | timestamp | When the migration was applied |
You should not modify this table directly, but inspecting it can help debug migration issues.
Advanced SQL: Stored Procedures and Block Comments#
The migration runner handles more than simple semicolon-delimited statements. It correctly parses:
$$delimited blocks -- PostgreSQL stored procedures and functions//blocks -- alternative delimiter blocks/* */block comments -- skipped during parsing--line comments -- skipped during parsing
A PostgreSQL stored procedure migration works without issues:
CREATE OR REPLACE FUNCTION update_modified_column()RETURNS TRIGGER AS $$BEGIN NEW.updated_at = NOW(); RETURN NEW;END;$$ LANGUAGE plpgsql;โCREATE TRIGGER set_updated_at BEFORE UPDATE ON products FOR EACH ROW EXECUTE FUNCTION update_modified_column();No special configuration needed. The SQL splitter recognises $$ boundaries and treats the block as a single statement.
A Real Migration Sequence#
migrations/โโโ 20260322143000_create_products_table.sqlโโโ 20260322143000_create_products_table.down.sqlโโโ 20260322143100_create_users_table.sqlโโโ 20260322143100_create_users_table.down.sqlโโโ 20260322143200_create_orders_table.sqlโโโ 20260322143200_create_orders_table.down.sqlโโโ 20260322143300_create_order_items_table.sqlโโโ 20260322143300_create_order_items_table.down.sqlโโโ 20260323091500_add_email_index_to_users.sqlThe index migration without a .down.sql -- this is fine. Down migrations are optional. If you roll back and no .down.sql exists, the tracking record is removed but no reversal SQL runs.
The index migration:
CREATE INDEX idx_users_email ON users (email);Its down file (20260323091500_add_email_index_to_users.down.sql), if you choose to create one:
DROP INDEX IF EXISTS idx_users_email;Migrations run in filename order. Each runs only once.
11. Query Caching#
For read-heavy applications:
TINA4_DB_CACHE=trueTina4 caches results of fetch() and fetchOne() calls. Identical queries with identical parameters return cached results instead of hitting the database.
The cache invalidates when you call execute(), insert(), update(), or delete() on the same table.
Per-query control:
// Force a fresh query (bypass cache)$products = $db->fetch("SELECT * FROM products", [], false); // third arg = use cacheโ// Clear the entire cache$db->clearCache();12. Exercise: Build a Notes App#
A notes application backed by SQLite. Migration for the table. Full CRUD API.
Requirements#
Create a migration for a
notestable:id-- integer, primary key, auto-incrementtitle-- text, not nullcontent-- text, not nulltag-- text, default "general"created_at-- text, default current timestampupdated_at-- text, default current timestamp
- Build these endpoints:
| Method | Path | Description |
|---|---|---|
GET | /api/notes | List all notes. Support ?tag= and ?search= filters. |
GET | /api/notes/{id:int} | Get a single note. 404 if not found. |
POST | /api/notes | Create a note. Validate title and content are not empty. |
PUT | /api/notes/{id:int} | Update a note. 404 if not found. |
DELETE | /api/notes/{id:int} | Delete a note. 204 on success, 404 if not found. |
Test with:#
# Createcurl -X POST http://localhost:7145/api/notes \ -H "Content-Type: application/json" \ -d '{"title": "Shopping List", "content": "Milk, eggs, bread", "tag": "personal"}'โ# List allcurl http://localhost:7145/api/notesโ# Searchcurl "http://localhost:7145/api/notes?search=shopping"โ# Filter by tagcurl "http://localhost:7145/api/notes?tag=personal"โ# Get onecurl http://localhost:7145/api/notes/1โ# Updatecurl -X PUT http://localhost:7145/api/notes/1 \ -H "Content-Type: application/json" \ -d '{"title": "Updated Shopping List", "content": "Milk, eggs, bread, butter"}'โ# Deletecurl -X DELETE http://localhost:7145/api/notes/113. Solution#
Migration#
Generate the migration:
tina4 generate migration create_notes_tableEdit migrations/20260322143000_create_notes_table.sql:
CREATE TABLE notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT NOT NULL, tag TEXT NOT NULL DEFAULT 'general', created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP);Edit migrations/20260322143000_create_notes_table.down.sql:
DROP TABLE IF EXISTS notes;Run it:
tina4 migrateRunning migrations... [APPLIED] 20260322143000_create_notes_table.sqlMigrations complete. 1 applied.Routes#
Create src/routes/notes.php:
<?phpuse Tina4\Router;use Tina4\Database;โ// List all notes with optional filtersRouter::get("/api/notes", function ($request, $response) { $db = Database::getConnection();โ $tag = $request->params["tag"] ?? ""; $search = $request->params["search"] ?? "";โ $sql = "SELECT * FROM notes"; $params = []; $conditions = [];โ if (!empty($tag)) { $conditions[] = "tag = :tag"; $params["tag"] = $tag; }โ if (!empty($search)) { $conditions[] = "(title LIKE :search OR content LIKE :search)"; $params["search"] = "%" . $search . "%"; }โ if (!empty($conditions)) { $sql .= " WHERE " . implode(" AND ", $conditions); }โ $sql .= " ORDER BY updated_at DESC";โ $notes = $db->fetch($sql, $params);โ return $response->json([ "notes" => $notes, "count" => count($notes) ]);});โ// Get a single noteRouter::get("/api/notes/{id:int}", function ($request, $response) { $db = Database::getConnection(); $id = $request->params["id"];โ $note = $db->fetchOne("SELECT * FROM notes WHERE id = :id", ["id" => $id]);โ if ($note === null) { return $response->json(["error" => "Note not found", "id" => $id], 404); }โ return $response->json($note);});โ// Create a noteRouter::post("/api/notes", function ($request, $response) { $db = Database::getConnection(); $body = $request->body;โ // Validate $errors = []; if (empty($body["title"])) { $errors[] = "Title is required"; } if (empty($body["content"])) { $errors[] = "Content is required"; } if (!empty($errors)) { return $response->json(["errors" => $errors], 400); }โ $db->execute( "INSERT INTO notes (title, content, tag) VALUES (:title, :content, :tag)", [ "title" => $body["title"], "content" => $body["content"], "tag" => $body["tag"] ?? "general" ] );โ $note = $db->fetchOne("SELECT * FROM notes WHERE id = last_insert_rowid()");โ return $response->json($note, 201);});โ// Update a noteRouter::put("/api/notes/{id:int}", function ($request, $response) { $db = Database::getConnection(); $id = $request->params["id"]; $body = $request->body;โ $existing = $db->fetchOne("SELECT * FROM notes WHERE id = :id", ["id" => $id]);โ if ($existing === null) { return $response->json(["error" => "Note not found", "id" => $id], 404); }โ $db->execute( "UPDATE notes SET title = :title, content = :content, tag = :tag, updated_at = CURRENT_TIMESTAMP WHERE id = :id", [ "title" => $body["title"] ?? $existing["title"], "content" => $body["content"] ?? $existing["content"], "tag" => $body["tag"] ?? $existing["tag"], "id" => $id ] );โ $note = $db->fetchOne("SELECT * FROM notes WHERE id = :id", ["id" => $id]);โ return $response->json($note);});โ// Delete a noteRouter::delete("/api/notes/{id:int}", function ($request, $response) { $db = Database::getConnection(); $id = $request->params["id"];โ $existing = $db->fetchOne("SELECT * FROM notes WHERE id = :id", ["id" => $id]);โ if ($existing === null) { return $response->json(["error" => "Note not found", "id" => $id], 404); }โ $db->execute("DELETE FROM notes WHERE id = :id", ["id" => $id]);โ return $response->json(null, 204);});Expected output for create:
{ "id": 1, "title": "Shopping List", "content": "Milk, eggs, bread", "tag": "personal", "created_at": "2026-03-22 14:30:00", "updated_at": "2026-03-22 14:30:00"}(Status: 201 Created)
Expected output for list:
{ "notes": [ { "id": 1, "title": "Shopping List", "content": "Milk, eggs, bread", "tag": "personal", "created_at": "2026-03-22 14:30:00", "updated_at": "2026-03-22 14:30:00" } ], "count": 1}Expected output for search:
{ "notes": [ { "id": 1, "title": "Shopping List", "content": "Milk, eggs, bread", "tag": "personal", "created_at": "2026-03-22 14:30:00", "updated_at": "2026-03-22 14:30:00" } ], "count": 1}Expected output for validation error:
{"errors": ["Title is required", "Content is required"]}(Status: 400 Bad Request)
14. Seeder -- Generating Test Data#
Testing with an empty database tells you nothing. Testing with hand-typed rows is slow and brittle. The FakeData class generates realistic test data. The seedTable() function inserts it in bulk.
FakeData#
use Tina4\FakeData;โ$fake = new FakeData();โ$fake->name(); // "Grace Lopez"$fake->email(); // "bob.anderson@demo.net"$fake->phone(); // "+1 (547) 382-9104"$fake->sentence(); // "Magna exercitation lorem ipsum dolor sit amet consectetur."$fake->paragraph(); // Four sentences of filler text$fake->integer(); // 7342$fake->decimal(); // 481.29$fake->date(); // "2023-07-14"$fake->uuid(); // "a3f1b2c4-d5e6-f7a8-b9c0-d1e2f3a4b5c6"$fake->address(); // "742 Oak Ave, Tokyo"$fake->boolean(); // trueEvery method draws from built-in word banks. No network calls. No external packages.
Deterministic Output#
Pass a seed to get reproducible results. The same seed produces the same sequence every time:
$fake = new FakeData(42);$fake->name(); // Always "Wendy White" with seed 42$fake->email(); // Always the same email with seed 42Deterministic data means deterministic assertions. This matters for tests.
Seeding a Table#
seedTable() combines FakeData with your database. Pass a field map -- an associative array where each key is a column name and each value is a callable that generates data:
use Tina4\FakeData;use Tina4\Database;use function Tina4\seedTable;โ$db = Database::getConnection();$fake = new FakeData(1);โseedTable($db, "users", 100, [ "name" => [$fake, "name"], "email" => [$fake, "email"], "phone" => [$fake, "phone"], "bio" => [$fake, "sentence"],]);This inserts 100 rows into the users table. Each row calls $fake->name(), $fake->email(), and so on to generate values. The function commits after all rows insert.
Overrides#
Static values that apply to every row go in the overrides array:
seedTable($db, "users", 50, [ "name" => [$fake, "name"], "email" => [$fake, "email"],], [ "role" => "member", "active" => 1,]);Every row gets role = "member" and active = 1. The field map generates the rest.
When to Use It#
- Populating a development database with realistic data
- Writing integration tests that need rows in the database
- Load testing with thousands of records
- Demos and screenshots that look real without using real data
15. Gotchas#
1. Forgetting commit()#
Problem: Queries run inside startTransaction(), but changes vanish on the next request.
Cause: No commit(). The transaction rolls back when the connection closes.
Fix: Always call $db->commit() on success. Use try/catch with $db->rollback() in the catch.
2. Connection String Formats#
Problem: Database refuses to connect. Cryptic error about the connection string.
Cause: Missing port. A common mistake is mysql://user:pass@host/db without the port number.
Fix: Always include the port:
| Engine | Default Port |
|---|---|
| PostgreSQL | 5432 |
| MySQL | 3306 |
| MSSQL | 1433 |
| Firebird | 3050 |
| SQLite | (file path, no port) |
3. SQLite File Paths#
Problem: SQLite creates a new empty database instead of using the existing one.
Cause: Wrong slash count. sqlite:// (two slashes) instead of sqlite:/// (three slashes). Or a relative path resolving to the wrong directory.
Fix: Three slashes for a relative path: sqlite:///data/app.db. Four slashes for absolute: sqlite:////var/data/app.db. The third slash separates the scheme. The fourth starts the absolute path.
4. Parameterised Queries with LIKE#
Problem: WHERE name LIKE :q with ["q" => "%search%"] works. WHERE name LIKE '%:q%' does not.
Cause: Parameters inside quotes are literal text, not placeholders.
Fix: Put the % wildcards in the parameter value: ["q" => "%" . $search . "%"]. The SQL should be WHERE name LIKE :q.
5. Boolean Values in SQLite#
Problem: You insert true or false. The database stores 1 or 0. Reading it back gives integers, not booleans.
Cause: SQLite has no native boolean type. Booleans become integers.
Fix: Cast in PHP: "in_stock" => (bool) $row["in_stock"]. Or accept that 1 and 0 work as truthy/falsy.
6. Migration Already Applied#
Problem: You edited a migration file and ran tina4 migrate again. Nothing changed.
Cause: Tina4 tracks applied migrations by filename in the tina4_migration table. Once applied, a migration will not run again regardless of content changes.
Fix: Create a new migration for schema changes. Never edit applied migrations. For early development, use tina4php migrate:rollback first, then tina4 migrate to reapply.
7. Down Migration Not Found During Rollback#
Problem: tina4php migrate:rollback removes the tracking record but does not reverse the schema change.
Cause: The .down.sql file is missing, or its name does not match the migration. For 20260322143000_create_products_table.sql, the down file must be 20260322143000_create_products_table.down.sql -- same base name with .down.sql appended before the extension.
Fix: Always generate migrations with tina4 generate migration which creates both files automatically. If you created migration files manually, add the .down.sql file with the exact matching name.
8. fetch() Returns Empty Array, Not Null#
Problem: if ($result === null) never matches, even when the table is empty.
Cause: fetch() always returns an array. Empty result is [], not null. Only fetchOne() returns null.
Fix: Check with if (empty($result)) or if (count($result) === 0).