Databases are core to most applications. This guide covers connection management, query patterns, transactions, data validation, and migration strategies that keep data integrity and application performance in sync.
SPIN uses PDO through the ConnectionManager. Connections are expensive; reuse them efficiently.
Create one connection per database and reuse it:
// config-dev.json
{
"database": {
"connections": {
"default": {
"driver": "mysql",
"host": "localhost",
"port": 3306,
"database": "app_dev",
"username": "${env:DB_USER}",
"password": "${env:DB_PASSWORD}"
}
}
}
}
// In your code
$db = app()->getContainer()->get(Connection::class);The container registers a single instance; all dependencies receive the same connection.
For high-traffic apps, consider external pooling:
- PgBouncer (PostgreSQL) — connection pooler, reduces backend overhead
- ProxySQL (MySQL) — advanced pooling, query routing, caching
- Redis (as queue) — offload work to background jobs
// Use pooled connection
$config = config('database');
$db = new PDO(
$config['pooled_dsn'], // Points to pooler, not database
$config['username'],
$config['password']
);Handle connection failures gracefully:
try {
$result = $db->fetch('SELECT 1');
} catch (PDOException $e) {
if (strpos($e->getMessage(), 'Lost connection') !== false) {
logger()->error('Database disconnected', ['exception' => $e]);
// Reconnect or queue for retry
$this->reconnect();
}
throw $e;
}SPIN uses parameterized queries with PDO, avoiding injection vulnerabilities:
class UserRepository
{
public function findByEmail(string $email): ?User
{
// Parameterized — safe
$row = $this->db->fetch(
'SELECT * FROM users WHERE email = ?',
[$email]
);
return $row ? User::fromRow($row) : null;
}
public function findByEmailOrPhone(string $email, string $phone): array
{
// IN clause with multiple params
$rows = $this->db->fetchAll(
'SELECT * FROM users WHERE email = ? OR phone = ?',
[$email, $phone]
);
return array_map(fn($row) => User::fromRow($row), $rows);
}
public function findByIds(array $ids): array
{
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$rows = $this->db->fetchAll(
"SELECT * FROM users WHERE id IN ($placeholders)",
$ids
);
return array_map(fn($row) => User::fromRow($row), $rows);
}
}Advantages:
- Direct control, no abstraction overhead
- Easy to write complex queries
- Runs exactly what you intend
Disadvantages:
- Repetitive for simple CRUD
- Manual result mapping
- Migration to different databases requires rewriting
If your team prefers a builder, integrate a PSR library like Doctrine or Eloquent:
// Using a hypothetical builder
$user = QueryBuilder::table('users')
->where('email', $email)
->first();
$users = QueryBuilder::table('users')
->where('created_at', '>', date('-30 days'))
->orderBy('name')
->limit(10)
->get();Use transactions to ensure data consistency across multiple operations:
try {
$this->db->beginTransaction();
// Deduct from account A
$this->db->execute(
'UPDATE accounts SET balance = balance - ? WHERE id = ?',
[$amount, $fromId]
);
// Add to account B
$this->db->execute(
'UPDATE accounts SET balance = balance + ? WHERE id = ?',
[$amount, $toId]
);
// Record transfer
$this->db->execute(
'INSERT INTO transfers (from_id, to_id, amount) VALUES (?, ?, ?)',
[$fromId, $toId, $amount]
);
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollback();
logger()->error('Transfer failed', ['exception' => $e]);
throw new TransferFailedException('Transaction rolled back');
}Some databases support savepoints for complex workflows:
$this->db->beginTransaction();
try {
$order = $this->createOrder($data);
$this->db->execute('SAVEPOINT before_items');
try {
foreach ($data['items'] as $item) {
$this->addOrderItem($order->id, $item);
}
} catch (\Throwable $e) {
$this->db->execute('ROLLBACK TO SAVEPOINT before_items');
// Handle item addition failure
}
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollback();
throw $e;
}Choose appropriate isolation levels for your workload:
// MySQL
$this->db->execute('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
$this->db->beginTransaction();
// ... your operations ...
$this->db->commit();| Level | Dirty Reads | Non-Repeatable Reads | Phantoms | Use Case |
|---|---|---|---|---|
| READ UNCOMMITTED | Yes | Yes | Yes | Never (data integrity issues) |
| READ COMMITTED | No | Yes | Yes | Default for most (balance consistency vs. concurrency) |
| REPEATABLE READ | No | No | Yes | MySQL default, prevents most anomalies |
| SERIALIZABLE | No | No | No | Critical operations, conflicts with concurrency |
Enforce invariants in the database:
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
age INT NOT NULL CHECK (age >= 18 AND age <= 150),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
status ENUM('pending', 'confirmed', 'shipped', 'delivered') DEFAULT 'pending',
amount DECIMAL(10, 2) NOT NULL CHECK (amount > 0),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);Validate before inserting to give users feedback:
class UserService
{
public function create(array $data): User
{
// Validate shape
if (empty($data['email'])) {
throw new ValidationException(['email' => 'Email required']);
}
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
throw new ValidationException(['email' => 'Invalid email format']);
}
// Validate uniqueness
if ($this->users->findByEmail($data['email'])) {
throw new ValidationException(['email' => 'Email already registered']);
}
// Validate age constraint
if (($data['age'] ?? null) < 18) {
throw new ValidationException(['age' => 'Must be 18 or older']);
}
// Safe to insert
return $this->users->save(new User(...$data));
}
}Track schema changes in version-controlled migration files:
// migrations/001_create_users.php
return new Migration(
version: '001',
name: 'create_users',
up: function (Connection $db) {
$db->execute('
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
');
},
down: function (Connection $db) {
$db->execute('DROP TABLE users');
}
);For large tables, plan migrations to minimize locks:
// BAD: Direct column rename locks table
ALTER TABLE users RENAME COLUMN name TO full_name;
// GOOD: Add new, migrate data, remove old
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
UPDATE users SET full_name = name;
ALTER TABLE users DROP COLUMN name;Deploy code and schema changes independently:
- Add new column (backward-compatible)
- Deploy new code that writes to both columns
- Backfill data in background job
- Deploy final code that only reads new column
- Drop old column after verification
Indexes trade write performance for query speed:
-- Fast for common queries
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id_created ON orders(user_id, created_at DESC);
-- Covering index: query answered without touching main table
CREATE INDEX idx_products_active ON products(active) INCLUDE (name, price);
-- Avoid excessive indexes (slow inserts/updates)Monitor slow queries and add indexes as needed:
// Enable slow query log (MySQL)
// SET GLOBAL slow_query_log = 'ON';
// SET GLOBAL long_query_time = 1;- Reuse connections — single instance per database via container
- Use parameterized queries — prevents SQL injection
- Wrap multi-step operations in transactions — ensures consistency
- Validate at both layers — database schema constraints + application logic
- Version control migrations — track schema changes like code
- Plan large migrations carefully — minimize locks, test first
- Index strategically — measure impact, avoid premature indexing
- Monitor query performance — slow logs guide optimization
See also: Performance-Optimization.md, Testing-Patterns.md, User-Guide/Databases.md