Database
Marko’s database layer uses an entity-driven approach with the Data Mapper pattern. Your PHP entities define the schema — no separate migration files to write by hand.
composer require marko/database marko/database-pgsqlConfigure your connection in config/database.php:
<?php
declare(strict_types=1);
return [ 'driver' => 'pgsql', 'host' => env('DB_HOST', 'localhost'), 'port' => (int) env('DB_PORT', '5432'), 'database' => env('DB_DATABASE', 'marko'), 'username' => env('DB_USERNAME', 'marko'), 'password' => env('DB_PASSWORD', ''),];Defining Entities
Section titled “Defining Entities”Entities are plain PHP classes. Marko infers the database schema from PHP types:
<?php
declare(strict_types=1);
namespace App\Blog\Entity;
use Marko\Database\Attributes\Column;use Marko\Database\Attributes\Table;use Marko\Database\Entity\Entity;use DateTimeImmutable;
#[Table('posts')]class Post extends Entity{ #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null;
#[Column] public string $title;
#[Column] public string $body;
#[Column] public bool $published = false;
#[Column] public ?string $createdAt = null;}Type Inference
Section titled “Type Inference”Marko maps PHP types to database columns automatically:
| PHP Type | PostgreSQL | MySQL |
|---|---|---|
int | INTEGER | INT |
string | TEXT | VARCHAR(255) |
bool | BOOLEAN | TINYINT(1) |
float | DOUBLE PRECISION | DOUBLE |
DateTimeImmutable | TIMESTAMP | DATETIME |
Migrations
Section titled “Migrations”Generate and run migrations from your entity definitions:
# Generate a migration from entity changesmarko db:migrate
# Roll back the last migrationmarko db:rollback
# Reset and re-run all migrationsmarko db:reset
# Check migration statusmarko db:statusQuerying
Section titled “Querying”Query Builder
Section titled “Query Builder”Use QueryBuilderInterface for fluent query building:
<?php
declare(strict_types=1);
namespace App\Blog\Repository;
use Marko\Database\Query\QueryBuilderInterface;use DateTimeImmutable;
readonly class PostRepository{ public function __construct( private QueryBuilderInterface $queryBuilder, ) {}
public function findById(int $id): ?array { return $this->queryBuilder->table('posts') ->where('id', '=', $id) ->first(); }
public function findPublished(): array { return $this->queryBuilder->table('posts') ->where('published', '=', true) ->orderBy('created_at', 'DESC') ->get(); }
public function create(string $title, string $body): int { return $this->queryBuilder->table('posts')->insert([ 'title' => $title, 'body' => $body, 'published' => false, 'created_at' => new DateTimeImmutable(), ]); }}Repositories
Section titled “Repositories”Extend Repository for entity-aware queries. findAll() and findBy() return an EntityCollection — an iterable, countable collection with filter, map, sortBy, groupBy, chunk, and pluck methods.
<?php
declare(strict_types=1);
namespace App\Blog\Repository;
use App\Blog\Entity\Post;use Marko\Database\Entity\EntityCollection;use Marko\Database\Repository\Repository;
class PostRepository extends Repository{ protected const ENTITY_CLASS = Post::class;
public function findBySlug(string $slug): ?Post { return $this->findOneBy(['slug' => $slug]); }
public function findPublished(): EntityCollection { return $this->query() ->where('status', '=', 'published') ->orderBy('created_at', 'desc') ->getEntities(); }}query() returns a RepositoryQueryBuilder pre-scoped to the repository’s table. It exposes the full query builder (where, whereIn, whereNotNull, joins, orderBy, limit, etc.) and terminates with getEntities() for an EntityCollection or firstEntity() for a single hydrated entity. Fall back to get() / first() only when you want raw arrays (reports, aggregates).
Use with() to eager-load relationships and avoid N+1 queries. Nested relationships use dot notation:
$posts = $postRepository->with('comments', 'tags')->findAll();$posts = $postRepository->with('comments.author')->findAll();Use matching() with QuerySpecification objects to compose reusable query logic:
use App\Blog\Query\PublishedSpec;use App\Blog\Query\RecentSpec;
$posts = $postRepository->matching( new PublishedSpec(), new RecentSpec(limit: 5),);See the Database package reference for the full Relationships, EntityCollection, and Query Specifications API.
Seeders
Section titled “Seeders”Populate your database with test or default data:
<?php
declare(strict_types=1);
namespace App\Blog\Database\Seeder;
use Marko\Database\Query\QueryBuilderInterface;use Marko\Database\Seed\SeederInterface;use DateTimeImmutable;
readonly class PostSeeder implements SeederInterface{ public function __construct( private QueryBuilderInterface $queryBuilder, ) {}
public function run(): void { $this->queryBuilder->table('posts')->insert([ 'title' => 'Hello World', 'body' => 'Welcome to Marko.', 'published' => true, 'created_at' => new DateTimeImmutable(), ]); }}marko db:seedSwitching Database Drivers
Section titled “Switching Database Drivers”Thanks to the interface/implementation split, switching from MySQL to PostgreSQL (or vice versa) is a Composer swap. Each driver package automatically binds ConnectionInterface to its implementation via its module.php:
# Remove the old driver, install the new onecomposer remove marko/database-mysqlcomposer require marko/database-pgsqlThat’s it — no binding changes needed. The driver package handles the wiring. Update your config/database.php connection settings to match the new driver, and your application code stays the same since it depends on ConnectionInterface, not a specific driver.
Next Steps
Section titled “Next Steps”- Authentication — user management and guards
- Caching — cache query results
- Testing — test database interactions
- Database package reference — full API details