Database persistence for the Scafera framework. Wraps Doctrine ORM/DBAL internally — userland code never imports Doctrine types outside of entities and repositories.
Provides: Database persistence for Scafera —
EntityStore(read/write),Transaction(savepoint-based nesting; unflushed writes throw at end-of-request), Scafera-owned mapping attributes underScafera\Database\Mapping\Field\*, and a Doctrine-free Schema API for migrations. Doctrine ORM/DBAL is wrapped internally.Depends on: A Scafera host project with a
DATABASE_URLenv var (set inconfig/config.yamlunderenv:or as OS env), entities insrc/Entity/, repositories insrc/Repository/, migrations insupport/migrations/, and seeders insupport/seeds/.Extension points:
- Contract —
SeederInterface(auto-taggedscafera.seeder, collected bydb:seed)- Migrations — extend
Scafera\Database\Migration;up()/down()use the Schema API (Schema::create/modify/drop, chainable column modifiers, no Doctrine imports)- Mapping attributes — 18 field types under
Scafera\Database\Mapping\Field\*(Id,Varchar,Text,Integer,Decimal,Money,DateTime,Json,Uuid, … plusColumnas an escape hatch for custom Doctrine types);#[Table]for table-name override;Auditabletrait forcreatedAt/updatedAt- Controlled zones —
src/Entity/forbids Doctrine imports (Scafera mapping only);src/Repository/allows Doctrine QueryBuilder / DQL / DBAL- Config — override via
doctrine:inconfig/config.yaml(note: engine name leaks; futuredatabase:key planned)Not responsible for: Doctrine imports outside
src/Entity/andsrc/Repository/(blocked byDoctrineBoundaryPass) · lifecycle callbacks on entities (detected and rejected) · table-name pluralization (singular snake_case per ADR-050) · engine abstraction in config (doctrine:key leaks engine name; mapping todatabase:planned) · test fixtures beyond seeders.
This is a capability package. It adds optional persistence to a Scafera project. It does not define folder structure or architectural rules — those belong to architecture packages.
composer require scafera/databaseThe bundle is auto-discovered via Scafera's symfony-bundle type detection. No manual registration needed.
- PHP 8.4+
scafera/kernel^1.0- A
DATABASE_URLenvironment variable (set inconfig/config.yamlunderenv:or as an OS env var)
The primary persistence interface. Inject it in repositories.
use Scafera\Database\EntityStore;
final class OrderRepository
{
public function __construct(
private readonly EntityStore $entityStore,
) {}
public function find(int $id): ?Order
{
return $this->entityStore->find(Order::class, $id);
}
public function save(Order $order): void
{
$this->entityStore->persist($order);
}
public function remove(Order $order): void
{
$this->entityStore->remove($order);
}
}Wraps writes in an explicit transaction. All persist() and remove() calls must be committed through Transaction::run() — unflushed writes are detected and throw at the end of the request/command.
use Scafera\Database\EntityStore;
use Scafera\Database\Transaction;
final class OrderService
{
public function __construct(
private readonly EntityStore $entityStore,
private readonly Transaction $tx,
) {}
public function placeOrder(array $data): Order
{
return $this->tx->run(function () use ($data): Order {
$order = new Order();
$order->setTotal($data['total']);
$this->entityStore->persist($order);
return $order;
});
}
}Nested Transaction::run() calls are safe — inner calls use database savepoints. Only the outermost level flushes and commits. If an inner call throws and the outer catches it, the inner changes are rolled back via savepoint while the outer transaction can still commit.
Entities use Scafera mapping attributes from Scafera\Database\Mapping\Field. These map to Doctrine types internally but keep your entities free of Doctrine imports.
use Scafera\Database\Mapping\Field;
final class Order
{
#[Field\Id]
private ?int $id = null;
#[Field\Decimal(precision: 10, scale: 2)]
private string $total;
public function __construct(
#[Field\Varchar]
private string $customerName,
) {}
}Available field attributes: Id, Varchar, VarcharShort, Text, Integer, IntegerBig, IntegerBigPositive, Boolean, Decimal, Money, Percentage, Date, DateTime, Time, UnixTimestamp, Json, Uuid, Column (escape hatch for custom Doctrine types).
For types not covered by the built-in attributes, use Column as an escape hatch:
#[Field\Column(type: 'string', options: ['length' => 15])]
private string $isoCode;Use the Auditable trait for createdAt/updatedAt timestamp fields. You must initialize $this->createdAt = new \DateTimeImmutable() in your entity constructor — the AuditableInitValidator will warn if you forget.
Table names are derived as singular snake_case from the class name: Order → order, BlogPost → blog_post. No pluralization is applied (see ADR-050). To override the default, use #[Table]:
use Scafera\Database\Mapping\Field;
use Scafera\Database\Mapping\Table;
#[Table(name: 'categories')]
final class Category
{
#[Field\Id]
private ?int $id = null;
}Repositories are the only zone where direct Doctrine usage is allowed. The DoctrineBoundaryPass enforces this at build time.
| Zone | Doctrine Imports | Notes |
|---|---|---|
src/Entity/ |
Forbidden | Use Scafera\Database\Mapping\Field attributes. Lifecycle callbacks detected and rejected. |
src/Repository/ |
Allowed | Controlled leakage — QueryBuilder, DQL, DBAL all permitted. |
| Everywhere else | Forbidden | Except Doctrine\Common\Collections (data structure, not behavioral). |
Migration files live in support/migrations/ and use the Scafera Schema API — zero Doctrine imports.
use Scafera\Database\Migration;
use Scafera\Database\Schema\Schema;
use Scafera\Database\Schema\Table;
final class Version20260403080718 extends Migration
{
public function up(Schema $schema): void
{
$schema->create('page', function (Table $table) {
$table->id();
$table->string('title', 255);
$table->string('slug', 255);
$table->text('content');
$table->boolean('published');
$table->timestamp('createdAt');
});
}
public function down(Schema $schema): void
{
$schema->drop('page');
}
}| Method | Doctrine Type | Arguments |
|---|---|---|
id() |
integer (auto-increment PK) |
— |
string($name, $length) |
string |
length (default 255) |
text($name) |
text |
— |
integer($name) |
integer |
— |
bigInteger($name) |
bigint |
— |
smallInteger($name) |
smallint |
— |
boolean($name) |
boolean |
— |
timestamp($name) |
datetime_immutable |
— |
date($name) |
date_immutable |
— |
decimal($name, $precision, $scale) |
decimal |
precision (default 8), scale (default 2) |
json($name) |
json |
— |
Column methods return a ColumnBuilder. Chain modifiers on it:
$table->string('bio')->nullable();
$table->boolean('active')->default(true);
$table->string('notes')->nullable()->default('');// Create a table
$schema->create('users', function (Table $table) { ... });
// Drop a table (destructive)
$schema->drop('users');
// Modify an existing table
$schema->modify('users', function (Table $table) {
$table->string('email', 255); // add column
$table->dropColumn('legacy_field'); // drop column (destructive)
});The following operations are planned as future additions to the Schema API:
- Indexes (regular, unique, composite)
- Foreign key constraints
Operations are classified as safe or destructive by type:
| Operation | Destructive |
|---|---|
CreateTable |
No |
AddColumn |
No |
DropTable |
Yes |
DropColumn |
Yes |
db:migrate checks pending migrations before execution:
- Development (
APP_ENV=dev): warns about destructive operations, proceeds - Production (
APP_ENV=prod): blocks execution unless--forceis passed
All commands are available via vendor/bin/scafera:
# Generate a migration from entity changes
vendor/bin/scafera db:migrate:diff
# Create a blank migration for manual editing
vendor/bin/scafera db:migrate:create
# Generate a migration to drop a specific table
vendor/bin/scafera db:migrate:drop orders
# Run pending migrations
vendor/bin/scafera db:migrate
# Show migration status
vendor/bin/scafera db:migrate:status
# Rollback the last migration
vendor/bin/scafera db:migrate:rollback
# Drop all tables and re-run all migrations (requires --force)
vendor/bin/scafera db:reset --force
# Run seeders from support/seeds/
vendor/bin/scafera db:seed
# List all database tables with column/row counts
vendor/bin/scafera db:schema:list
# Show column definitions for a table
vendor/bin/scafera db:schema:show orders
# Show mismatches between entities and database
vendor/bin/scafera db:schema:diffSeeders live in support/seeds/ and are auto-discovered via the scafera.seeder tag:
namespace App\Seed;
use Scafera\Database\EntityStore;
use Scafera\Database\SeederInterface;
use Scafera\Database\Transaction;
final class PageSeed implements SeederInterface
{
public function __construct(
private readonly EntityStore $entityStore,
private readonly Transaction $transaction,
) {}
public function run(): void
{
$this->transaction->run(function (): void {
$page = new \App\Entity\Page();
$page->setTitle('Welcome');
$page->setSlug('welcome');
$page->setContent('Hello world.');
$page->setPublished(true);
$this->entityStore->persist($page);
});
}
}The bundle configures Doctrine defaults automatically:
- DBAL: reads
DATABASE_URLfrom env - ORM: maps
App\Entityfromsrc/Entity/with attribute mapping - Migrations: stores migration files in
support/migrations/under theApp\Migrationsnamespace
To override Doctrine config, add a doctrine: section to config/config.yaml. Note: this leaks the engine name — a database: config key mapping is planned (see Roadmap).
MIT