Build and work with Laravel Paper flat-file Eloquent models, including the
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 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.
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.
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 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.
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.
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);
{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();
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.
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]
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]);
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.
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.
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);
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.
How can I help you explore Laravel packages today?