jacobjoergensen/laravel-paper
Laravel Paper adds flat-file driver support for Eloquent, letting models read Markdown and JSON files as records. Two PHP attributes and a trait give you Eloquent's familiar query API, no schema or database connection needed.
Requirements first. Laravel Paper targets Laravel 12+ on PHP 8.4+, so check your runtime before adding it to an existing app. On older projects this is a blocker, not a warning.
composer require jacobjoergensen/laravel-paper
Create content, not migrations. Drop a file into a content directory. The filename minus the extension becomes the slug and therefore the primary key.
---
title: Building a Blog with Flat Files
published: true
date: "2024-03-15"
---
Your Markdown content goes here...
content/posts/building-a-blog.md is now addressable as building-a-blog.
Point a model at the directory. Two attributes plus the trait — no connection, no schema, no $table:
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;
protected $casts = [
'published' => 'boolean',
'date' => 'date',
];
}
First query. Everything after this is ordinary Eloquent:
$posts = Post::where('published', true)
->orderBy('date', 'desc')
->get();
$post = Post::find('building-a-blog');
Where to look first. The README gets you to a working model in five minutes; laravel-paper.com is the real reference and the place to check before assuming an Eloquent method exists. Read the changelog next — the package ships parity features incrementally, and the release notes are the most accurate record of which query methods, relationships, and binding behaviours actually exist in your installed version. If your project uses Laravel Boost, run php artisan boost:install and accept the laravel-paper-development skill; it feeds your AI agent Paper-specific constraints instead of letting it hallucinate with() and whereHas().
Best first use case. Content that lives in git and is read far more than it is written: a blog, docs pages, changelog entries, marketing copy, seed/reference data, or a small internal catalogue. The sweet spot is dozens to low hundreds of files where "editing a record" means "opening a PR".
One model per content directory, one directory per content type. Post, Author, Doc each get their own #[ContentPath]. Cross-type links are stored as slugs in frontmatter and resolved with the paper relationship helpers or an explicit lookup — there are no joins, so model the data as denormalised documents rather than a normalised schema.
JSON driver for structured data. Markdown is for prose with a frontmatter header; #[Driver('json')] is the right fit for records that are essentially arrays. This is also where casts earn their keep — array, json, object, and collection casts round-trip to the file as native structures (fixed properly in 1.10), so nested data doesn't get double-encoded.
Use the Eloquent surface you already know, but stay inside the supported set. Query methods available include where/orWhere, whereIn/whereNotIn, whereBetween, whereLike/whereNotLike, whereAny/whereAll, whereNull semantics, orderBy/orderByDesc with multi-column tie-breaking, pluck (with a key column), value, firstWhere, sole, exists, findMany, count/min/max/sum/avg, paginate/simplePaginate, chunk/each/lazy, inRandomOrder, and when/unless. Prefer simplePaginate for long lists — it avoids counting every file.
Writes are first-class now. save, create, firstOrCreate, updateOrCreate, firstOrNew, updateOrCreate, bulk update across matches, delete, plus saveQuietly/deleteQuietly. Use them for admin panels and imports; use updateOrCreate for idempotent content sync jobs so a re-run doesn't duplicate slugs.
Post::updateOrCreate(
['slug' => Str::slug($title)],
['title' => $title, 'published' => false, 'date' => now()->toDateString()],
);
Wire events, not observers on a database. Model events (creating, created, saving, saved, updating, updated, deleting, deleted, and retrieved as of 1.11) are the extension point for cache invalidation, search-index updates, and sitemap regeneration. retrieved fires per model returned by a query and is skipped on count, exists, pluck, and bulk delete — useful for lazy hydration side effects, but don't rely on it firing uniformly.
Relationships are lookups, not joins. belongsToPaper gives O(1) slug-based parent resolution; hasManyPaper reads every related file on each call. Treat them as conveniences for single-record pages, and for list views load the related set once (findMany or a pluck) into a keyed array instead of touching the relation per row.
Route model binding works out of the box, including {post:slug}-style field binding and scoped child bindings via a hasManyPaper relation. Register controllers normally; a /authors/{author}/posts/{post} route 404s when the child belongs to another parent.
Validate with the package rules. PaperRule and PaperUniqueRule slot into FormRequests and mirror Laravel's exists/unique semantics, including PaperUniqueRule::ignore($slug, 'column') for excluding the record being edited and translated messages that respect the app locale.
Scope and collect. Declare query scopes with Laravel's #[Scope] attribute (protected methods included) and swap in a custom collection with #[CollectedBy]. #[Timestamps] exposes the file's modification time as updated_at while created_at stays a frontmatter field — great for "last edited" badges, but read the gotcha below before relying on it.
Custom drivers via DriverRegistry. If YAML or a bespoke format is a better fit, register a driver rather than forking. Be deliberate about extension ordering, since a slug existing under two extensions must resolve to one record.
Put content under CI discipline. Store content in the repo, run php artisan paper:validate in CI to catch malformed frontmatter and slug collisions hidden behind duplicate files, and only allow writes in environments with a writable filesystem.
The filesystem is your database, so you inherit its properties. There are no transactions and no rollback: a bulk update that fails halfway leaves earlier files written. save is atomic per file (temp file + rename), which protects against torn writes but not against multi-file inconsistency. If a workflow must be all-or-nothing, stage changes in memory, validate, and write in a controlled batch — or reconsider whether this dataset belongs in a real database.
Every query is a directory scan and a parse. Filtering and ordering by frontmatter fields means reading files. The package optimises what it can: count skips parsing with no where clauses, unordered first/exists short-circuit, paginate/simplePaginate read only the current page when there's no where and no frontmatter ordering, and the directory is listed once rather than per extension. But an orderBy('date') over 5,000 files still parses 5,000 files. Keep content small, cache aggressively, and use chunk/each for bulk processing rather than get().
The slug is the primary key, so renaming is a key change. save moves the file when the slug changes and returns false if it would land on an existing slug rather than overwriting it — handle that return value. A listener can set or rewrite the slug during saving/creating. delete removes the file the model was loaded from, so a mutated slug won't delete someone else's record.
Validate slugs yourself, not just in the package. Path traversal in find/save/delete is fixed by enforcing a single safe filename segment, but that means slugs containing slashes are rejected outright. Generate with Str::slug() or a ULID and keep the slug column out of user-editable free text.
Frontmatter types are inferred, and loose comparison bites. '1' from YAML matches integer 1 in whereIn (intentional, matching where), booleans arrive as booleans or strings depending on the driver. Declare $casts explicitly and write where('published', true) rather than relying on truthiness.
find respects pending constraints. A filtered builder's find will not return a record the where excludes — correct, but surprising if you built a scoped query and expected a lookup to bypass it. Use a fresh query when you want an unconditional lookup.
where handles null, precedence, and function-named columns correctly as of 1.15 — and binds tighter than or, a null value becomes a null check, and a column called date is treated as a column. Older versions silently dropped records in mixed and/or chains, so don't copy those patterns forward.
#[Timestamps] and mtime are not a reliable audit trail. File modification time changes on clone, deploy, rsync, and container rebuild. It's fine for "sort by recently touched" locally; never use it as authoritative updated_at in an environment that re-materialises files. Also note 1.15 fixed a bug where it clobbered a real frontmatter field named by UPDATED_AT.
Saving reformats Markdown. Since 1.11 frontmatter is written in block style and content-only files get no empty block, so the first save of existing files produces diff noise. Review those diffs before committing; it's formatting only.
Long-running processes deserve suspicion. File-modification lookups are memoised in-process for the request, and #[Driver]/#[ContentPath] resolve on first use. In queue workers and Octane, verify you're seeing fresh state after external file changes rather than trusting the memo. Clear caches after content deploys.
lazy lists files up front — it's lazy in parsing, not in discovery. hasManyPaper re-reads every related file per call, so it's the relationship most likely to blow up a loop.
Debugging checklist when a record "doesn't exist": confirm the extension matches the driver, confirm #[ContentPath] is relative to the project root, run paper:validate, then check for a slug collision across extensions. Malformed frontmatter throws FileParseException naming the file — read that path before assuming a query bug.
Production writes. Serverless and read-only filesystems make save/delete fail. Treat Paper as read-only in production and route edits through your normal deploy pipeline, or explicitly provision a writable, persistent content volume.
How can I help you explore Laravel packages today?