Database (Hopper)
Hopper is a thin wrapper around PDO. It handles the connection, parameter binding, and a handful of fetch shapes you write over and over.
Connect
Hopper takes a config array or object. Don't hard-code credentials in code. Keep them in an INI file under app/config/ and load them with $App->getConfig() (or $App->load() in init.php) — that way credentials live outside source control and can differ per environment.
MySQL
Drop your connection details in app/config/MySQL.ini:
driver = "mysql"
host = "localhost"
username = "root"
password = "secret"
database = "myapp"
charset = "utf8mb4"
port = 3306
Then in init.php, read the file and hand the section to Hopper:
$App->db = new \Truecast\Hopper($App->getConfig('MySQL.ini'));
SQLite
SQLite has no credentials — only a file path — so it's fine to inline. The path lives in the project (not a secret), and the constant BP resolves to your project root:
$App->dbLite = new \Truecast\Hopper([
'driver' => 'sqlite',
'database' => BP.'/app/data/myapp.sqlite',
]);
If you'd still rather keep the filename in config — for example to swap it per environment — use the same pattern:
app/config/SQLite.ini
driver = "sqlite"
database = "myapp.sqlite"
$App->dbLite = new \Truecast\Hopper($App->getConfig('SQLite.ini'));
Already gitignored by default. The starter .gitignore shipped with TrueFramework keeps these out of source control automatically:
app/config
app/data
tests
**/.DS_Store
tmp
.claude
.ddev
logs
So your MySQL.ini and any SQLite files in app/data/ are safe out of the box — no extra setup required. If you want a starter for teammates, commit a sanitized template like MySQL.ini.example at the project root (or anywhere outside app/config/) and have each developer copy it into place.
Reading data
The get() method takes a SQL query, optional parameters, and a return-shape hint:
$user = $App->db->get("SELECT * FROM users WHERE id = ?", [$id], 'object');
$users = $App->db->get("SELECT * FROM users", null, 'objects');
Return shapes
$type | Returns |
|---|---|
'array' | One row, associative array |
'arrays' | All rows, array of associative arrays |
'object' | One row, stdClass |
'objects' | All rows, array of stdClass |
'list' | Single column from many rows, flat array |
'value' | Scalar — first column of first row |
'number' | Scalar number |
'keypair' | Two-column query as [key => value] |
$count = $App->db->get("SELECT COUNT(*) FROM users", null, 'value');
$emails = $App->db->get("SELECT email FROM users", null, 'list');
$idToName = $App->db->get("SELECT id, name FROM users", null, 'keypair');
Writing data
set() upserts based on the presence of an id field. No id = INSERT; id present = UPDATE:
// Insert — returns new id
$id = $App->db->set('users', [
'name' => 'Daniel',
'email' => 'd@example.com',
]);
// Update
$App->db->set('users', [
'id' => 7,
'email' => 'new@example.com',
]);
Bulk inserts skip the round-trip-per-row pattern:
$App->db->insertMultiple('users',
['name','email'],
[
['Alice', 'a@example.com'],
['Bob', 'b@example.com'],
]
);
Deleting
$App->db->delete('users', 7); // WHERE id = 7
$App->db->delete('users', [1, 2, 3]); // IN (1,2,3)
$App->db->delete('sessions', ['expired' => 1], 'expired'); // by another column
Raw queries
For DDL or anything outside the upsert/fetch sweet spot:
$App->db->query("CREATE TABLE logs (id INT PRIMARY KEY AUTO_INCREMENT, msg TEXT)");
$App->db->execute("UPDATE users SET active = 0 WHERE last_login < ?", [$cutoff]);
DDL caveat: execute() returns false when the statement reports zero affected rows — which is the normal outcome for CREATE TABLE, DROP TABLE, etc. For DDL, use query() and check $db->getErrors() instead.
Chaining
// Append ORDER BY without rewriting your SQL
$rows = $App->db->sort('created_at DESC')->get($sql, [], 'objects');
Errors & metadata
$App->db->getErrors(); // array of recorded errors
$App->db->getLastQuery(); // last SQL string run
$App->db->lastInsertId(); // last AUTO_INCREMENT value