Database — querying and transactions
A repository in this framework touches the database exclusively through DatabasePort, which repositories resolve from their scoped ModuleContainer. The kernel provides TransactionManager for nesting-aware transaction control, and expects adapters to support key features like portable upserts and multi-database migration.
DatabasePort methods
See Ports for the complete interface reference.
Portable upserts — not dialect-specific SQL
Never write ON DUPLICATE KEY UPDATE or ON CONFLICT … DO UPDATE by hand. Instead, call upsert():
$affected = $db->upsert(
table: 'invoices',
values: [
'id' => $invoiceId,
'total' => $total,
'status' => 'draft',
],
conflictColumns: ['id'],
updateColumns: ['total', 'status'], // null = all non-conflict cols
);The adapter compiles the correct grammar for its underlying database:
- MySQL:
INSERT INTO invoices (…) VALUES (…) ON DUPLICATE KEY UPDATE … - PostgreSQL:
INSERT INTO invoices (…) VALUES (…) ON CONFLICT (id) DO UPDATE SET … - SQLite:
INSERT INTO invoices (…) VALUES (…) ON CONFLICT(id) DO UPDATE SET … - SQL Server:
MERGE INTO invoices USING source WHEN MATCHED THEN UPDATE …
This is the ONLY correct way to write upsert logic in a repository — hand-writing it breaks cross-database portability.
Returning the auto-increment id
After an INSERT:
$db->execute('INSERT INTO users (email) VALUES (?)', [$email]);
$userId = $db->lastInsertId(); // MySQL/SQLite: ignore the arg
$userId = $db->lastInsertId('users_id_seq'); // PostgreSQL: pass the sequence namePostgreSQL requires the sequence name because a table may have multiple sequences; MySQL and SQLite ignore it.
Parameter binding
Always use parameterized queries. The adapter handles escaping and prevents SQL injection:
$rows = $db->query(
'SELECT * FROM invoices WHERE tenant_id = ? AND status = ?',
[$tenantId, 'issued']
);
$affected = $db->execute(
'UPDATE invoices SET status = ? WHERE id = ?',
['paid', $invoiceId]
);TransactionManager
Nesting-aware transaction control. Manages a depth counter so nested service calls don't double-commit or prematurely close a transaction.
final class TransactionManager
{
public function begin(): void;
public function commit(): void;
public function rollback(): void;
public function inTransaction(): bool;
}Typical usage in a service
final class InvoiceService
{
public function __construct(
private readonly InvoiceRepository $repository,
private readonly TransactionManager $transaction,
private readonly DomainEventCollector $collector,
private readonly EventBus $eventBus,
) {}
public function issue(string $invoiceId): void
{
$this->collector->beginCollection();
$this->transaction->begin();
try {
$invoice = $this->repository->find($invoiceId);
$invoice->issue();
foreach ($invoice->releaseEvents() as $event) {
$this->collector->collect($event);
}
$this->repository->save($invoice);
$this->transaction->commit();
} catch (\Throwable $e) {
$this->transaction->rollback();
$this->collector->discard(); // never persist phantom events
throw new ServiceException('invoice.issue.failed', previous: $e);
}
// AFTER commit, dispatch integration events
$this->eventBus->dispatch(new InvoiceIssuedIntegrationEvent(...));
}
}Nesting semantics
A nested begin() increments a depth counter; only the outermost commit() actually commits. This allows composing services:
public function createWithLineItems(array $items): Invoice
{
$this->transaction->begin();
try {
$invoice = $this->repository->create($data);
// This inner service ALSO calls begin/commit.
// The inner commit() increments depth but doesn't commit.
$this->lineItemService->addToInvoice($invoice->id(), $items);
$this->transaction->commit(); // outer commit — this one actually commits
} catch (\Throwable $e) {
$this->transaction->rollback(); // rolls back everything
throw new ServiceException('create.failed', previous: $e);
}
}If any nested call throws and rolls back, the entire transaction is rolled back.
Tenant-scoped database binding
For multi-tenant applications where each tenant connects to a different database, rebind DatabasePort per request in a module's register() method, NOT in withPorts():
// WRONG — this is app-lifetime:
Kernel::configure()->withPorts([
DatabasePort::class => $db, // shared across all tenants
])
// CORRECT — request-scoped rebinding:
final class TenancyProvider implements ModuleContract
{
public function register(ModuleContainer $container): void
{
$container->singleton(DatabasePort::class, function (ModuleContainer $c) {
$tenant = $c->make(TenantContext::class);
return new MySQLAdapter(config('databases.' . $tenant->slug));
});
}
}Why this matters: Under OpenSwoole, a static/app-lifetime binding leaks across coroutines. If one request reads from Tenant A's database and the next overwrites the connection with Tenant B's, both coroutines see Tenant B's data — a silent multi-tenancy break. Request-scoped rebinding isolates each request's container and its database connection.
DriverAware — checking the underlying SQL dialect
A DatabasePort may implement the optional DriverAware interface to declare its SQL dialect:
if ($db instanceof DriverAware) {
$driver = $db->driver(); // 'mysql' | 'pgsql' | 'sqlite' | 'sqlsrv'
}Use this when you must branch SQL for compatibility (e.g., a query builder that generates different SQL per database). Most code should avoid branching and write portable queries instead — hand-writing ON DUPLICATE KEY is the canonical example to avoid.
Transactions and the error pipeline
A service-layer exception triggers rollback BEFORE it reaches the error pipeline. The error pipeline logs the exception (and optionally notifies via Slack/mail), but by then the database change has been rolled back:
Service throws
↓
catch in Service
→ transaction->rollback()
→ collector->discard()
↓
throw ServiceException
↓
ErrorPipeline
→ log
→ notify Slack/mail
↓
HTTP 500 responseIntegration events are dispatched AFTER commit succeeds, so they are never sent for failed operations.
Performance: upsert atomicity
upsert() is atomic at the database level — either the INSERT happens or the UPDATE happens, never both, never neither. This makes it safe for:
- Single-flight deduplication (process one incoming API call atomically)
- Idempotent job retries (the job can be processed twice without duplicating side effects)
- Race-condition-safe counters
A query + update is not atomic and opens a race window.
Testing with a database fake
For unit tests, create an in-memory fake:
<?php declare(strict_types=1);
namespace Tests\Fixtures;
use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort;
final class InMemoryDatabase implements DatabasePort
{
private array $tables = [];
public function query(string $sql, array $params = []): array
{
// Parse and execute mock SQL...
}
public function queryOne(string $sql, array $params = []): ?array
{
$rows = $this->query($sql, $params);
return $rows[0] ?? null;
}
public function execute(string $sql, array $params = []): int
{
// Track affected rows...
}
public function upsert(string $table, array $values, array $conflictColumns, ?array $updateColumns = null): int
{
// Upsert logic...
}
// ... etc
}Use the fake for service tests so tests run fast and need no database setup.
Migration support
Use LetMigrate for database schema management. It is framework-agnostic, supports multiple databases, and compiles portable SQL:
// In a migration
$blueprint->createTable('invoices', function (Blueprint $table) {
$table->string('id', 26)->primary();
$table->string('tenant_id', 26);
$table->decimal('total', 10, 2); // integer cents
$table->enum('status', ['draft', 'issued', 'paid']);
$table->timestamps(); // created_at, updated_at
$table->softDeletes();
$table->index(['tenant_id', 'status']);
});The Blueprint API compiles to correct CREATE TABLE syntax per database.
Data access patterns
See Data Access for repository and hydrator patterns.
Common mistakes
WARNING
Never mutate a connection pool or port after boot. The core container is frozen after materialization, and modifying a connection string would affect every pending request. If a tenant needs a different database, rebind in the module's register() method (request-scoped), not in bootstrap.
**Never use getenv() or putenv()for configuration.** These are shared globals unsafe under OpenSwoole. Use theenv()helper (reads$_ENV/$_SERVER) or the config()` helper (compiled at boot).
Never store a DatabasePort reference in a static. It leaks across requests under OpenSwoole. Inject it into each service instance.