Skip to content

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.

Terminal window
composer require marko/database marko/database-pgsql

Configure your connection in config/database.php:

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', ''),
];

Entities are plain PHP classes. Marko infers the database schema from PHP types:

app/blog/Entity/Post.php
<?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;
}

Marko maps PHP types to database columns automatically:

PHP TypePostgreSQLMySQL
intINTEGERINT
stringTEXTVARCHAR(255)
boolBOOLEANTINYINT(1)
floatDOUBLE PRECISIONDOUBLE
DateTimeImmutableTIMESTAMPDATETIME

Generate and run migrations from your entity definitions:

Terminal window
# Generate a migration from entity changes
marko db:migrate
# Roll back the last migration
marko db:rollback
# Reset and re-run all migrations
marko db:reset
# Check migration status
marko db:status

Use QueryBuilderInterface for fluent query building:

app/blog/Repository/PostRepository.php
<?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(),
]);
}
}

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.

app/blog/Repository/PostRepository.php
<?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.

Populate your database with test or default data:

app/blog/Database/Seeder/PostSeeder.php
<?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(),
]);
}
}
Terminal window
marko db:seed

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:

Terminal window
# Remove the old driver, install the new one
composer remove marko/database-mysql
composer require marko/database-pgsql

That’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.