Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
laravel-paper-development
jacobjoergensen/laravel-paper
claude-code
cms
markdown
content
laravel
eloquent
frontmatter
flat-file

Build and work with Laravel Paper flat-file Eloquent models, including the

Install
php artisan boost:add-skill jacobjoergensen/laravel-paper

Save this content to: .claude/skills/laravel-paper-development/SKILL.md

# Laravel Paper Development

Laravel Paper adds a flat-file driver to Eloquent. Markdown, JSON, and YAML files in a content
directory become queryable, writable Eloquent models, configured with PHP 8 attributes and
the `Paper` trait instead of a database connection or migration.

## When to use this skill

Use this skill when a model uses the `JacobJoergensen\LaravelPaper\Paper` trait, or when
adding, querying, or writing the flat files that back those models.

For underlying Eloquent and Laravel behavior, use Boost's `search-docs` tool. This skill
only covers what Paper does differently.

## Defining a model

A Paper model extends `Model`, uses the `Paper` trait, and declares its format and content
directory with two attributes.

```php
use Illuminate\Database\Eloquent\Model;
use JacobJoergensen\LaravelPaper\Attributes\ContentPath;
use JacobJoergensen\LaravelPaper\Attributes\Driver;
use JacobJoergensen\LaravelPaper\Paper;

#[Driver('markdown')]
#[ContentPath('content/posts')]
class Post extends Model
{
    use Paper;
}
```

- `markdown` frontmatter becomes attributes, the body is exposed as `content`.
- `json` and `yaml` top-level keys become attributes. `yaml` reads `.yaml` and `.yml`.
- `#[Driver]` defaults to `markdown`, `#[ContentPath]` defaults to `content`.

## The slug is the primary key

The filename without its extension is the slug, and the slug is the primary key. There is
no auto-incrementing id.

```
content/posts/
├── hello-world.md     → slug: "hello-world"
└── my-second-post.md  → slug: "my-second-post"
```

```php
$post = Post::find('hello-world');
$posts = Post::findMany(['hello-world', 'my-second-post']);
```

To change a slug, rename the file. For a public URL that differs from the filename, add a
frontmatter field (e.g. `permalink`) and route on that instead of the slug.

## Querying

Query Paper models with the standard Eloquent query API.

```php
$posts = Post::where('published', true)->orderBy('date', 'desc')->get();
$post = Post::where('slug', 'hello-world')->first();
```

`where` and `orWhere` take an array of conditions, combined with `and`. With `orWhere` the `or`
applies to the whole array, so `orWhere([['a', 1], ['b', 2]])` needs both to match.

Paper adds `whereContains` for array-field membership. It matches rows where the array
attribute includes the given value:

```php
// Posts whose `tags` frontmatter list contains "laravel"
$laravelPosts = Post::whereContains('tags', 'laravel')->get();
```

Local query scopes work, including ones declared with Laravel's `#[Scope]` attribute. The
scope receives Paper's query builder, so type-hint `PaperQueryBuilder`, not Eloquent's
`Builder`:

```php
use Illuminate\Database\Eloquent\Attributes\Scope;
use JacobJoergensen\LaravelPaper\PaperQueryBuilder;

#[Scope]
protected function published(PaperQueryBuilder $query): PaperQueryBuilder
{
    return $query->where('published', true);
}

// Post::published()->get();
```

Global scopes work through `addGlobalScope` and `#[ScopedBy]`, and cover `find` and route
model binding too. A scope is a Closure taking `PaperQueryBuilder` or a class implementing
`ScopeContract`. Eloquent's `Scope` is ignored. An `or` in the query cannot widen past a
scope, unlike in Eloquent.

`#[CollectedBy]` is also respected, so queries return your model's custom collection.

## Large result sets

A query lists every file and builds a model per record it checks, parsing only files changed
since they were cached. Prefer `lazy` over `get`, and `simplePaginate` over `paginate` on a
filtered query, since counting the matches is what costs.

```php
foreach (Post::query()->lazy() as $post) {
    // ...
}

Post::chunk(100, function (Collection $posts): void {
    // ...
});

$posts = Post::simplePaginate(15);
```

## Route model binding

`{post}` binds on the slug, `{post:title}` on any frontmatter field. Scoped child bindings
resolve through the parent's `hasManyPaper` relation, named after the plural of the parameter.

```php
Route::get('/posts/{post}', fn (Post $post) => $post);
Route::get('/posts/{post:title}', fn (Post $post) => $post);

Route::get('/authors/{author}/posts/{post}', fn (Author $author, Post $post) => $post)
    ->scopeBindings();
```

## Aggregates

`count`, `min`, `max`, `sum`, `avg`, and the `average` alias work on the model and the query
builder. They read through casts and ignore `orderBy`/`limit`/`offset`, like SQL.

```php
$next = Post::max('order') + 1;
$views = Post::where('published', true)->sum('views');
```

`sum` and `avg` skip null, missing, and non-numeric values. `min` and `max` skip only null and
compare the rest with PHP's rules, so a column mixing numbers and text can return the text.

`countBy` counts a column's distinct values into a collection, flattening array fields like `tags`.

## Casts

Eloquent casts work as usual. `array`, `json`, `object`, and `collection` read and write
native YAML or JSON structures rather than encoded strings, so files stay hand-editable.

```php
protected function casts(): array
{
    return ['tags' => 'array', 'views' => 'integer'];
}
```

```yaml
# Stored as a real list, not a JSON string.
tags: [laravel, markdown]
```

## Writing

Save and delete go through the standard Eloquent API and fire the usual model events;
loading a record fires `retrieved`.

```php
$post = new Post();
$post->slug = 'hello-world';
$post->title = 'Hello World';
$post->content = 'My first post.';
$post->save();

$post->delete();
```

`create`, `firstOrCreate`, and `updateOrCreate` work from an attribute array. Like any
Eloquent model, these use mass assignment, so set `$fillable` (or `$guarded = []`) on the
model. The array must include the slug, since it is the key:

```php
Post::create(['slug' => 'hello-world', 'title' => 'Hello World']);
Post::updateOrCreate(['slug' => 'hello-world'], ['title' => 'Updated title']);
```

Bulk `update` writes each matching file in a loop. Model events fire per record, `$fillable`
does not apply, and it is not a single atomic operation:

```php
Post::where('draft', true)->update(['published' => true]);
```

## Timestamps

Paper models have no timestamps unless you add `#[Timestamps]`, which exposes the file's
modification time as `updated_at`.

```php
use JacobJoergensen\LaravelPaper\Attributes\Timestamps;

#[Driver('markdown')]
#[ContentPath('content/posts')]
#[Timestamps]
class Post extends Model
{
    use Paper;
}
```

`updated_at` comes from the file's mtime and is never written to frontmatter. `created_at`
is not derived; set it as a frontmatter field if you need it. A Git checkout resets mtimes
to deploy time, so use `#[Timestamps]` for content edited in place and keep a frontmatter
`date` for Git-deployed content.

## Relationships

Use `belongsToPaper` and `hasManyPaper`, and call them as methods, not properties.

```php
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use JacobJoergensen\LaravelPaper\Paper;

class Post extends Model
{
    use Paper;

    public function author(): ?Author
    {
        return $this->belongsToPaper(Author::class);
    }
}

class Author extends Model
{
    use Paper;

    public function posts(): Collection
    {
        return $this->hasManyPaper(Post::class);
    }
}

$author = Post::find('hello-world')->author();
$posts = Author::find('jane-doe')->posts();
```

Foreign keys default to `{model}_slug` (e.g. `author_slug`); pass a second argument to
override.

## Validation

Use `PaperRule` with Laravel's validator:

```php
use JacobJoergensen\LaravelPaper\Rules\PaperRule;

$request->validate([
    'slug' => ['required', PaperRule::unique(Post::class)],
    'author_slug' => ['required', PaperRule::exists(Author::class)],
]);

// Skip the current record when validating an update:
PaperRule::unique(Post::class)->ignore($post->slug);
```

## Custom drivers

`markdown`, `json`, and `yaml` are registered by default. To support another format, implement
`DriverContract` (`extensions`, `parse`, `serialize`) and register it in a service provider's
`boot` method, then point a model at it with `#[Driver('toml')]`.

```php
use JacobJoergensen\LaravelPaper\Drivers\DriverRegistry;

app(DriverRegistry::class)->register('toml', TomlDriver::class);
```

`parse` takes the file path and returns the attributes. `serialize` returns the file contents.

Order `extensions()` deliberately. New records are written with the first one, and when a slug
exists under several, the first one wins.

Laravel Paper Development

Laravel Paper adds a flat-file driver to Eloquent. Markdown, JSON, and YAML files in a content directory become queryable, writable Eloquent models, configured with PHP 8 attributes and the Paper trait instead of a database connection or migration.

When to use this skill

Use this skill when a model uses the JacobJoergensen\LaravelPaper\Paper trait, or when adding, querying, or writing the flat files that back those models.

For underlying Eloquent and Laravel behavior, use Boost's search-docs tool. This skill only covers what Paper does differently.

Defining a model

A Paper model extends Model, uses the Paper trait, and declares its format and content directory with two attributes.

use Illuminate\Database\Eloquent\Model;
use JacobJoergensen\LaravelPaper\Attributes\ContentPath;
use JacobJoergensen\LaravelPaper\Attributes\Driver;
use JacobJoergensen\LaravelPaper\Paper;

#[Driver('markdown')]
#[ContentPath('content/posts')]
class Post extends Model
{
    use Paper;
}
  • markdown frontmatter becomes attributes, the body is exposed as content.
  • json and yaml top-level keys become attributes. yaml reads .yaml and .yml.
  • #[Driver] defaults to markdown, #[ContentPath] defaults to content.

The slug is the primary key

The filename without its extension is the slug, and the slug is the primary key. There is no auto-incrementing id.

content/posts/
├── hello-world.md     → slug: "hello-world"
└── my-second-post.md  → slug: "my-second-post"
$post = Post::find('hello-world');
$posts = Post::findMany(['hello-world', 'my-second-post']);

To change a slug, rename the file. For a public URL that differs from the filename, add a frontmatter field (e.g. permalink) and route on that instead of the slug.

Querying

Query Paper models with the standard Eloquent query API.

$posts = Post::where('published', true)->orderBy('date', 'desc')->get();
$post = Post::where('slug', 'hello-world')->first();

where and orWhere take an array of conditions, combined with and. With orWhere the or applies to the whole array, so orWhere([['a', 1], ['b', 2]]) needs both to match.

Paper adds whereContains for array-field membership. It matches rows where the array attribute includes the given value:

// Posts whose `tags` frontmatter list contains "laravel"
$laravelPosts = Post::whereContains('tags', 'laravel')->get();

Local query scopes work, including ones declared with Laravel's #[Scope] attribute. The scope receives Paper's query builder, so type-hint PaperQueryBuilder, not Eloquent's Builder:

use Illuminate\Database\Eloquent\Attributes\Scope;
use JacobJoergensen\LaravelPaper\PaperQueryBuilder;

#[Scope]
protected function published(PaperQueryBuilder $query): PaperQueryBuilder
{
    return $query->where('published', true);
}

// Post::published()->get();

Global scopes work through addGlobalScope and #[ScopedBy], and cover find and route model binding too. A scope is a Closure taking PaperQueryBuilder or a class implementing ScopeContract. Eloquent's Scope is ignored. An or in the query cannot widen past a scope, unlike in Eloquent.

#[CollectedBy] is also respected, so queries return your model's custom collection.

Large result sets

A query lists every file and builds a model per record it checks, parsing only files changed since they were cached. Prefer lazy over get, and simplePaginate over paginate on a filtered query, since counting the matches is what costs.

foreach (Post::query()->lazy() as $post) {
    // ...
}

Post::chunk(100, function (Collection $posts): void {
    // ...
});

$posts = Post::simplePaginate(15);

Route model binding

{post} binds on the slug, {post:title} on any frontmatter field. Scoped child bindings resolve through the parent's hasManyPaper relation, named after the plural of the parameter.

Route::get('/posts/{post}', fn (Post $post) => $post);
Route::get('/posts/{post:title}', fn (Post $post) => $post);

Route::get('/authors/{author}/posts/{post}', fn (Author $author, Post $post) => $post)
    ->scopeBindings();

Aggregates

count, min, max, sum, avg, and the average alias work on the model and the query builder. They read through casts and ignore orderBy/limit/offset, like SQL.

$next = Post::max('order') + 1;
$views = Post::where('published', true)->sum('views');

sum and avg skip null, missing, and non-numeric values. min and max skip only null and compare the rest with PHP's rules, so a column mixing numbers and text can return the text.

countBy counts a column's distinct values into a collection, flattening array fields like tags.

Casts

Eloquent casts work as usual. array, json, object, and collection read and write native YAML or JSON structures rather than encoded strings, so files stay hand-editable.

protected function casts(): array
{
    return ['tags' => 'array', 'views' => 'integer'];
}
# Stored as a real list, not a JSON string.
tags: [laravel, markdown]

Writing

Save and delete go through the standard Eloquent API and fire the usual model events; loading a record fires retrieved.

$post = new Post();
$post->slug = 'hello-world';
$post->title = 'Hello World';
$post->content = 'My first post.';
$post->save();

$post->delete();

create, firstOrCreate, and updateOrCreate work from an attribute array. Like any Eloquent model, these use mass assignment, so set $fillable (or $guarded = []) on the model. The array must include the slug, since it is the key:

Post::create(['slug' => 'hello-world', 'title' => 'Hello World']);
Post::updateOrCreate(['slug' => 'hello-world'], ['title' => 'Updated title']);

Bulk update writes each matching file in a loop. Model events fire per record, $fillable does not apply, and it is not a single atomic operation:

Post::where('draft', true)->update(['published' => true]);

Timestamps

Paper models have no timestamps unless you add #[Timestamps], which exposes the file's modification time as updated_at.

use JacobJoergensen\LaravelPaper\Attributes\Timestamps;

#[Driver('markdown')]
#[ContentPath('content/posts')]
#[Timestamps]
class Post extends Model
{
    use Paper;
}

updated_at comes from the file's mtime and is never written to frontmatter. created_at is not derived; set it as a frontmatter field if you need it. A Git checkout resets mtimes to deploy time, so use #[Timestamps] for content edited in place and keep a frontmatter date for Git-deployed content.

Relationships

Use belongsToPaper and hasManyPaper, and call them as methods, not properties.

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use JacobJoergensen\LaravelPaper\Paper;

class Post extends Model
{
    use Paper;

    public function author(): ?Author
    {
        return $this->belongsToPaper(Author::class);
    }
}

class Author extends Model
{
    use Paper;

    public function posts(): Collection
    {
        return $this->hasManyPaper(Post::class);
    }
}

$author = Post::find('hello-world')->author();
$posts = Author::find('jane-doe')->posts();

Foreign keys default to {model}_slug (e.g. author_slug); pass a second argument to override.

Validation

Use PaperRule with Laravel's validator:

use JacobJoergensen\LaravelPaper\Rules\PaperRule;

$request->validate([
    'slug' => ['required', PaperRule::unique(Post::class)],
    'author_slug' => ['required', PaperRule::exists(Author::class)],
]);

// Skip the current record when validating an update:
PaperRule::unique(Post::class)->ignore($post->slug);

Custom drivers

markdown, json, and yaml are registered by default. To support another format, implement DriverContract (extensions, parse, serialize) and register it in a service provider's boot method, then point a model at it with #[Driver('toml')].

use JacobJoergensen\LaravelPaper\Drivers\DriverRegistry;

app(DriverRegistry::class)->register('toml', TomlDriver::class);

parse takes the file path and returns the attributes. serialize returns the file contents.

Order extensions() deliberately. New records are written with the first one, and when a slug exists under several, the first one wins.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle