Docs LATEST

Content Drivers

The ContentDriver contract, FileDriver, and HTTP-backed indexing patterns for VelvetCMS.

Services

Primary contract: VelvetCMS\Contracts\ContentDriver


ContentDriver Interface #

Defines how pages load, persist, enumerate, and report freshness. VelvetCMS\Drivers\Content\FileDriver ships with Core and is bound to content.driver. Remote gateways must implement every interface method, including lastModified().

Definition #

interface ContentDriver
{
    /**
     * @throws \VelvetCMS\Exceptions\NotFoundException
     */
    public function load(string $slug): Page;

    /**
     * @throws \VelvetCMS\Exceptions\ValidationException
     */
    public function save(Page $page): bool;

    public function list(array $filters = []): Collection;

    public function paginate(int $page = 1, int $perPage = 20, array $filters = []): Collection;

    /**
     * @throws \VelvetCMS\Exceptions\NotFoundException
     */
    public function delete(string $slug): bool;

    public function exists(string $slug): bool;

    public function count(array $filters = []): int;

    public function lastModified(string $slug): ?int;
}

Methods #

load() #

Load a page by its slug.

public function load(string $slug): Page
Parameter Type Description
$slug string The page slug

Returns: Page instance

Throws: NotFoundException if page doesn't exist

Example:

$driver = app('content.driver');

try {
    $page = $driver->load('welcome');
    echo $page->title;
} catch (NotFoundException $e) {
    // Handle missing page
}

save() #

Save a page (create or update).

public function save(Page $page): bool
Parameter Type Description
$page Page The page to save

Returns: true on success

Throws: ValidationException if page data is invalid

Example:

$page = new Page();
$page->slug = 'new-page';
$page->title = 'New Page';
$page->content = '# Hello World';
$page->status = 'draft';

$driver->save($page);

list() #

Get all pages matching filters.

public function list(array $filters = []): Collection
Parameter Type Description
$filters array Optional filter criteria

Returns: Collection of Page objects

Example:

// All pages
$pages = $driver->list();

// Published pages only
$published = $driver->list(['status' => 'published']);

// With ordering
$recent = $driver->list([
    'order_by' => 'updated_at',
    'order_dir' => 'desc',
    'limit' => 10,
]);

paginate() #

Get paginated pages.

public function paginate(int $page = 1, int $perPage = 20, array $filters = []): Collection
Parameter Type Default Description
$page int 1 Page number
$perPage int 20 Items per page
$filters array [] Filter criteria

Returns: Collection of Page objects

Example:

$pages = $driver->paginate(2, 15, ['status' => 'published']);

delete() #

Delete a page by slug.

public function delete(string $slug): bool
Parameter Type Description
$slug string The page slug

Returns: true on success

Throws: NotFoundException if page doesn't exist

Example:

$driver->delete('old-page');

exists() #

Check if a page exists.

public function exists(string $slug): bool

Example:

if ($driver->exists('about')) {
    $page = $driver->load('about');
}

count() #

Count pages matching filters.

public function count(array $filters = []): int

Example:

$total = $driver->count();
$published = $driver->count(['status' => 'published']);

lastModified() #

Return the last modified timestamp for a page, or null when the page does not exist.

$mtime = $driver->lastModified('about');

Filters #

The built-in file driver accepts these indexed filters:

Filter Type Description
status string 'draft' or 'published'
order_by string created_at, updated_at, published_at, slug, title, or status
order_dir string 'asc' or 'desc'
limit int Maximum results
offset int Skip first N results

Example:

$pages = $driver->list([
    'status' => 'published',
    'order_by' => 'created_at',
    'order_dir' => 'desc',
    'limit' => 5,
]);

Built-in Driver #

FileDriver #

Stores pages as files in user/content/pages/.

Namespace: VelvetCMS\Drivers\Content\FileDriver

Features:

  • Loads .vlt files first, then .md
  • Uses YAML frontmatter for metadata
  • Maintains a page index through the PageIndex abstraction
  • Supports JSON and SQLite index backends
  • Keeps write operations file-native while making reads and lists efficient

File Structure:

---
title: Welcome
status: published
created_at: 2026-01-01
---

# Welcome to VelvetCMS

Your content here...

Configuration:

// config/content.php
'drivers' => [
    'file' => [
        'path' => content_path('pages'),
        'index' => [
            'driver' => 'json',
        ],
    ],
],

Custom implementations #

If you want a different storage backend, implement ContentDriver and override the container binding for content.driver.

use VelvetCMS\Contracts\ContentDriver;

$app->singleton('content.driver', fn () => new MyContentDriver(...));
$app->alias('content.driver', ContentDriver::class);

PageService and the rest of Core resolve the driver through the contract, so your custom implementation can replace the default cleanly.

Creating a Custom Driver #

Implement ContentDriver end-to-end. The sketch below uses HttpClient with structured options (query / json) and send() for verbs without sugar helpers (for example HEAD):

use VelvetCMS\Contracts\ContentDriver;
use VelvetCMS\Database\Collection;
use VelvetCMS\Exceptions\NotFoundException;
use VelvetCMS\Http\Client\HttpClient;
use VelvetCMS\Models\Page;

final class ApiDriver implements ContentDriver
{
    public function __construct(
        private readonly HttpClient $client,
        private readonly string $baseUrl,
    ) {}

    private function endpoint(string $path): string
    {
        return rtrim($this->baseUrl, '/') . '/' . ltrim($path, '/');
    }

    public function load(string $slug): Page
    {
        $response = $this->client->get($this->endpoint("/pages/{$slug}"));

        if ($response->status() === 404) {
            throw new NotFoundException("Page '{$slug}' not found");
        }

        /** @var array<string, mixed> $payload */
        $payload = $response->json();

        return Page::fromArray($payload);
    }

    public function save(Page $page): bool
    {
        $response = $this->client->put($this->endpoint("/pages/{$page->slug}"), [
            'json' => $page->toArray(),
        ]);

        return $response->successful();
    }

    /** @param array<string, mixed> $filters */
    public function list(array $filters = []): Collection
    {
        $response = $this->client->get($this->endpoint('/pages'), [
            'query' => $filters,
        ]);

        /** @var list<array<string, mixed>> $rows */
        $rows = $response->json();

        $pages = array_map(
            static fn (array $row) => Page::fromArray($row),
            $rows,
        );

        return new Collection($pages);
    }

    public function paginate(int $page = 1, int $perPage = 20, array $filters = []): Collection
    {
        return $this->list(array_merge($filters, [
            'page' => $page,
            'per_page' => $perPage,
        ]));
    }

    public function delete(string $slug): bool
    {
        $response = $this->client->delete($this->endpoint("/pages/{$slug}"));

        if ($response->status() === 404) {
            throw new NotFoundException("Page '{$slug}' not found");
        }

        return $response->successful();
    }

    public function exists(string $slug): bool
    {
        $response = $this->client->send('HEAD', $this->endpoint("/pages/{$slug}"));

        return $response->status() === 200;
    }

    /** @param array<string, mixed> $filters */
    public function count(array $filters = []): int
    {
        $response = $this->client->get($this->endpoint('/pages/count'), [
            'query' => $filters,
        ]);

        /** @var array<string, mixed> $payload */
        $payload = $response->json();

        return (int) ($payload['count'] ?? 0);
    }

    public function lastModified(string $slug): ?int
    {
        $response = $this->client->send('HEAD', $this->endpoint("/pages/{$slug}"));

        if ($response->status() === 404) {
            return null;
        }

        $header = $response->header('Last-Modified');

        return $header !== null ? (strtotime($header) ?: null) : null;
    }
}

Register Custom Driver #

use VelvetCMS\Contracts\ContentDriver;
use VelvetCMS\Http\Client\HttpClient;

$app->singleton('content.driver', fn ($app) => new ApiDriver(
    $app->make(HttpClient::class),
    (string) config('content.drivers.api.url'),
));
$app->alias('content.driver', ContentDriver::class);

Usage with PageService #

The PageService wraps content drivers with caching and events:

// Direct driver access (no caching)
$driver = app('content.driver');
$page = $driver->load('welcome');

// Via PageService (with caching and events)
$pages = app('pages');
$page = $pages->load('welcome'); // Cached for 300s

See PageService for the recommended high-level API.