Use this skill for PDO, repositories, SQL, migrations, transactions, database configuration, and persistence rules.
Preferred database access:
Core\Database wraps PDO and exposes these methods:
| Method | Returns | Description |
|---|---|---|
query(string $sql, array $params = []) |
array |
Runs a SELECT and returns all rows as associative arrays |
first(string $sql, array $params = []) |
?array |
Runs a SELECT and returns the first row, or null |
execute(string $sql, array $params = []) |
bool |
Runs INSERT / UPDATE / DELETE |
lastInsertId() |
string |
Returns the last auto-increment ID as a string — cast to int for integer PKs |
transaction(callable $fn) |
mixed |
Runs $fn($db) inside a transaction; commits on success, rolls back and rethrows on failure |
pdo() |
PDO |
Returns the raw PDO instance for advanced use |
lastInsertId() is only meaningful immediately after an execute() INSERT on the same connection. Calling it at any other point returns "0".
Typical INSERT + ID retrieval in a repository:
public function create(Employee $employee): int
{
$this->database->execute(
'INSERT INTO employees (first_name, email) VALUES (:first_name, :email)',
['first_name' => $employee->firstName, 'email' => $employee->email]
);
return (int) $this->database->lastInsertId();
}
Use PDO or a well-maintained database abstraction layer/ORM.
Never concatenate untrusted input into SQL.
Bad:
$sql = "SELECT * FROM users WHERE id = " . $_GET['id'];
Good:
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$user = $stmt->fetch(PDO::FETCH_ASSOC);
Rules:
Use Database::transaction() when multiple writes must succeed or fail together. It begins a transaction, runs the callback, commits on success, and rolls back and rethrows on any Throwable.
$database->transaction(function (Database $db) use ($order): void {
$orders->create($order);
$auditLog->record('order.created', $order->id());
});
Do not call $database->pdo()->beginTransaction() directly for transaction management — use transaction() instead. Reserve pdo() for driver-specific features that have no Database API equivalent.
$_GET, $_POST, or other superglobals.findById, findAllActive, and save over generic magic calls.The migration system is made up of three files:
core/Migration.php — abstract base class all migration files extendcore/MigrationManager.php — runs, rolls back, and tracks applied migrationscore/helpers.php — provides the migration_manager() helper that wires a MigrationManager to the app database and the database/migrations/ pathThe CLI entry point is scripts/migrate.php. Run it from the project root:
| Command | Description |
|---|---|
php scripts/migrate.php up |
Run all pending migrations |
php scripts/migrate.php down [steps] |
Roll back the last N migrations (default: 1) |
php scripts/migrate.php status |
Show which migrations have run and when |
php scripts/migrate.php make <name> |
Scaffold a new timestamped migration file |
php scripts/migrate.php fresh |
Roll back everything and re-run from scratch |
php scripts/migrate.php fresh --seed |
Same as fresh, then run the employee seed |
Migration files live in database/migrations/ and are named YYYYMMDD_HHMMSS_<slug>.php.
Each file must return a Migration instance:
<?php
declare(strict_types=1);
use Core\Database;
use Core\Migration;
return new class extends Migration
{
public function up(Database $database): void
{
$database->execute('CREATE TABLE ...');
}
public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS ...');
}
};
INSERT OR IGNORE (SQLite) and INSERT IGNORE (MySQL) are not portable — use a check-then-insert pattern instead.migrations table are permanent. A row means “this migration ran against this database.” Deleting a migration file does not remove the record and does not allow the migration to be re-run. To intentionally re-run a migration, delete its row from the migrations table manually — this makes the action explicit.php scripts/migrate.php fresh, which rolls back all migrations in reverse order and re-runs them from scratch.Before completing database work, verify:
.env.example.Example .env.example:
APP_ENV=local
APP_DEBUG=true
DATABASE_URL=mysql://user:password@localhost:3306/app
Powered by TurnKey Linux.