AppKit integrates Doctrine ORM for entity mapping and persistence, a fluent QueryBuilder for raw DBAL queries, and a DoctrineOrmPagination helper for paginated results.
config/doctrine.php returns a closure that receives an OrmConfigurator instance.
// config/doctrine.php
use Modufolio\Appkit\Doctrine\OrmConfigurator;
return function (OrmConfigurator $orm) use ($projectDir): void {
$orm->connection([
'driver' => 'pdo_sqlite',
'path' => $projectDir . '/database/data.db',
])->entities(
$projectDir . '/src/Entity'
);
};entities() is variadic — pass multiple paths to scan entity classes from more than one directory. Paths are additive; calling entities() again appends rather than replaces.
$orm->connection([...])->entities(
$projectDir . '/src/Entity',
$projectDir . '/packages/billing/src/Entity',
$projectDir . '/packages/cms/src/Entity',
);$orm->connection([
'driver' => 'pdo_mysql',
'host' => getenv('DB_HOST'),
'dbname' => getenv('DB_NAME'),
'user' => getenv('DB_USER'),
'password' => getenv('DB_PASSWORD'),
'charset' => 'utf8mb4',
])->entities($projectDir . '/src/Entity');$orm->connection([
'driver' => 'pdo_pgsql',
'host' => getenv('DB_HOST'),
'dbname' => getenv('DB_NAME'),
'user' => getenv('DB_USER'),
'password' => getenv('DB_PASSWORD'),
])->entities($projectDir . '/src/Entity');| Method | Description |
|---|---|
connection(array $params) |
Set DBAL connection parameters |
entities(string ...$paths) |
Scan one or more directories for entity classes (variadic — pass as many paths as you need) |
addFilter(string $name, string $class) |
Register a Doctrine filter (e.g. soft delete) |
middlewares(array $middlewares) |
Add DBAL middleware stack entries |
addSubscriber(EventSubscriber $subscriber) |
Register a Doctrine event subscriber |
cache(?CacheItemPoolInterface $metadata, ?CacheItemPoolInterface $query, ?CacheItemPoolInterface $result) |
Configure caches for metadata, queries, and results |
OrmConfigurator has no API for custom column types, and AppKit never registers
any itself. Register them by hand inside the closure, before configuring the
connection:
// config/doctrine.php
use Doctrine\DBAL\Types\Type;
use Ramsey\Uuid\Doctrine\UuidType;
return function (OrmConfigurator $orm): void {
if (!Type::hasType(UuidType::NAME)) {
Type::addType(UuidType::NAME, UuidType::class);
}
$orm->connection([...])->entities(...);
};The hasType() guard matters: the closure runs once per environment, but in a
long-running worker or a test suite that boots the app repeatedly, a bare
addType() throws on the second call.
config/<env>/doctrine.php does not extend config/doctrine.php — the kernel
requires exactly one file:
$closure = require $this->fileMap['doctrine'];
$closure($configurator);There is no merge and no fallback. Anything the base config does — custom types, filters, middleware — must be repeated in each environment file that replaces it.
This is the most common way to break a test suite: entities declare
#[ORM\Column(type: 'uuid')], the base config registers UuidType, the test
config does not, and every test dies in setUp() with
Unknown column type "uuid" requested — an error that points at Doctrine rather
than at the config file that caused it.
The way out is to make the environment file delegate to the base file and override only what differs:
// config/test/doctrine.php
use Modufolio\Appkit\Doctrine\OrmConfigurator;
return function (OrmConfigurator $orm) use ($projectDir): void {
// Run the base config first — types, filters, middleware, entity paths.
(require dirname(__DIR__) . '/doctrine.php')($orm);
// Then override only the connection.
$orm->connection([
'driver' => 'pdo_sqlite',
'memory' => true,
]);
};This works because connection() replaces its parameters wholesale, custom
types are guarded by hasType(), and addFilter() overwrites by name. Only
entities() appends — so do not call it again in the environment file unless
you are adding an extra path.
Entity classes live in src/Entity/. Use Doctrine PHP attributes for mapping.
// src/Entity/Post.php
namespace App\Entity;
use App\Repository\PostRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: PostRepository::class)]
#[ORM\Table(name: 'posts')]
class Post
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
private string $title = '';
#[ORM\Column(type: 'text')]
private string $body = '';
public function getId(): ?int { return $this->id; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $title): void { $this->title = $title; }
// ...
}Repositories extend Doctrine\ORM\EntityRepository and are registered in config/repositories.php.
// src/Repository/PostRepository.php
namespace App\Repository;
use App\Entity\Post;
use Doctrine\ORM\EntityRepository;
/**
* @extends EntityRepository<Post>
*/
class PostRepository extends EntityRepository
{
public function findPublished(): array
{
return $this->createQueryBuilder('p')
->where('p.published = true')
->orderBy('p.createdAt', 'DESC')
->getQuery()
->getResult();
}
}Register it:
// config/repositories.php
return [
PostRepository::class => Post::class,
];Inject EntityManagerInterface via config/controllers.php:
// config/controllers.php
use Doctrine\ORM\EntityManagerInterface;
return [
PostController::class => [
EntityManagerInterface::class,
],
];In the controller:
class PostController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
public function create(#[MapRequestPayload] CreatePostDto $dto): ResponseInterface
{
$post = new Post();
$post->setTitle($dto->title);
$post->setBody($dto->body);
$this->entityManager->persist($post);
$this->entityManager->flush();
return Response::redirect($this->urlGenerator->generate('post.show', ['id' => $post->getId()]));
}
}Two approaches — choose based on your situation.
New projects (development):
php bin/console orm:schema-tool:createCreates tables directly from entity definitions. Fast for greenfield setup but does not create migration files.
Existing databases (production and teams):
# Generate a migration from the diff between entities and current schema
php bin/console migrations:diff
# Review the generated file in database/migrations/
# Then apply it
php bin/console migrations:migrateNever run
orm:schema-tool:createagainst an existing database. Usemigrations:migratefor any database that already has data.
php bin/console migrations:diff # Generate migration from entity changes
php bin/console migrations:migrate # Run all pending migrations
php bin/console migrations:status # Show current version and pending count
php bin/console migrations:list # List all migrations and their status
php bin/console migrations:execute # Run a specific migrationMigrations are stored in database/migrations/. Each class extends AbstractMigration and implements up() and down().
// database/migrations/Version20260507194151.php
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE posts (id INTEGER NOT NULL, title VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE posts');
}Modufolio\Appkit\Doctrine\QueryBuilder is a fluent wrapper around DBAL's query builder for raw SQL queries without ORM overhead. The API is inspired by the Laravel Query Builder.
use Modufolio\Appkit\Doctrine\QueryBuilder;
$qb = new QueryBuilder($this->entityManager->getConnection());
$posts = $qb->from('posts')
->select('id', 'title', 'created_at')
->where('published', '=', true)
->orderBy('created_at', 'DESC')
->limit(10)
->get();| Method | Description |
|---|---|
from(string $table, ?string $alias) |
Set the table |
select(string ...$columns) |
Select columns |
selectRaw(string $expr, array $bindings) |
Select a raw expression; ? placeholders bind in order |
where(string $col, string $op, mixed $val) |
Add a WHERE condition |
orWhere(string $col, string $op, mixed $val) |
Add an OR WHERE condition |
whereIn(string $col, array $values) |
WHERE IN clause |
whereNotIn(string $col, array $values) |
WHERE NOT IN clause |
whereNull(string $col) |
WHERE IS NULL |
whereNotNull(string $col) |
WHERE IS NOT NULL |
whereRaw(string $expr, array $bindings) |
Raw WHERE expression; ? placeholders bind in order, e.g. whereRaw('age > ?', [25]) |
join(...) |
INNER JOIN |
leftJoin(...) |
LEFT JOIN |
rightJoin(...) |
RIGHT JOIN |
orderBy(string $col, string $dir) |
ORDER BY |
groupBy(string ...$cols) |
GROUP BY |
limit(int $n) |
LIMIT |
offset(int $n) |
OFFSET |
insert(array $values) |
INSERT — returns affected rows |
update(array $values) |
UPDATE — returns affected rows |
delete() |
DELETE — returns affected rows |
get() |
Fetch all rows as array |
first() |
Fetch first row or null |
count() |
Fetch count |
fetchColumn(string $col) |
Fetch a single column as array |
toSql() |
Return the SQL string |
DoctrineOrmPagination paginates a Doctrine Query object.
use Modufolio\Appkit\Doctrine\DoctrineOrmPagination;
$query = $this->entityManager
->createQuery('SELECT p FROM App\Entity\Post p ORDER BY p.createdAt DESC');
$pagination = (new DoctrineOrmPagination())->paginate($query, page: $page, limit: 20);
$items = $pagination->getResults();
$total = $pagination->total();
$pages = $pagination->pages();
$hasPrev = $pagination->hasPrevPage();
$hasNext = $pagination->hasNextPage();
$range = $pagination->range(5); // array of page numbers around current page| Method | Returns | Description |
|---|---|---|
paginate(Query, int $page = 1, int $limit = 10) |
self |
Instance method; populates and returns $this |
getResults() |
array |
Current page items |
total() |
int |
Total item count |
limit() |
int |
Items per page |
page() |
int |
Current page number |
pages() |
int |
Total page count |
offset() |
int |
Current page offset |
firstPage() |
int |
Always 1 |
lastPage() |
int |
Same as pages() |
isFirstPage() |
bool |
|
isLastPage() |
bool |
|
hasPrevPage() |
bool |
|
prevPage() |
?int |
|
hasNextPage() |
bool |
|
nextPage() |
?int |
|
range(int $range = 5) |
array |
Page numbers centred on current page |
hasPages() |
bool |
true if more than one page |
start() |
int |
Index of first item on current page |
end() |
int |
Index of last item on current page |
Register SoftDeleteFilter to exclude soft-deleted records from all queries automatically.
// config/doctrine.php
$orm->addFilter('soft_delete', \Modufolio\Appkit\Doctrine\Filter\SoftDeleteFilter::class);Your entity needs a deletedAt column. Add a #[ORM\Column(nullable: true)] property of type DateTimeImmutable. When deletedAt is not null, the record is hidden from queries while the filter is enabled.
Enable or disable the filter at runtime:
$this->entityManager->getFilters()->enable('soft_delete');
$this->entityManager->getFilters()->disable('soft_delete');// Direct DQL
$users = $this->entityManager
->createQuery('SELECT u FROM App\Entity\User u WHERE u.enabled = true')
->getResult();
// With parameters
$user = $this->entityManager
->createQuery('SELECT u FROM App\Entity\User u WHERE u.email = :email')
->setParameter('email', $email)
->getOneOrNullResult();For quick SQL inspection during development:
php bin/console dbal:run-sql "SELECT COUNT(*) FROM users"