Entry.log

Published

Laravel Paper: Flat-File Eloquent for Modern Laravel

Laravel Paper is a small package with a very clear promise: use Eloquent with flat files. Instead of setting up a database table for every content-like thing in a Laravel app, you can point a model at a directory of Markdown or JSON files and keep working with familiar Eloquent queries.

That idea is appealing because a surprising amount of application data is not really relational in the first place. Blog posts, docs pages, team profiles, changelog entries, marketing content, and small internal registries often fit naturally as files. The usual Laravel tradeoff is that once you go flat-file, you lose the Eloquent ergonomics that the rest of the framework is built around. Laravel Paper tries to remove that tradeoff.

The repository was published on March 18, 2026, and the pitch is simple: a flat-file Eloquent driver for modern Laravel.

What Laravel Paper does

At a high level, Laravel Paper lets you define an Eloquent model that reads and writes records from files on disk instead of rows in a database.

The package supports:

  • Markdown-backed models.
  • JSON-backed models.
  • Eloquent-style querying.
  • Save, update, and delete through the usual model API.
  • Pagination.
  • Validation helpers.
  • Flat-file relationships.
  • Custom driver support for other file formats.

The package uses PHP 8 attributes plus a trait, which keeps the model definitions compact.

Why this is interesting

There are already flat-file CMS tools, and there are already ways to hand-roll content loading from Markdown files. The interesting part here is not simply “you can read Markdown in Laravel.” The interesting part is that the package keeps you inside the Eloquent mental model.

That means you can keep writing things like:

  • Post::where('published', true)
  • Post::orderBy('date', 'desc')
  • Post::paginate(15)
  • Post::create([...])

That is a useful middle ground for projects that do not want the operational weight of a database for content-heavy sections, but still want the ergonomics of models, queries, and validation.

Example 1: a flat-file blog powered by Markdown

The most obvious and most common use case is a blog or documentation section.

Define a Markdown-backed model

The package uses attributes to define the driver and the content path:

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;
}

This tells Laravel Paper to treat the files in content/posts as Post records.

The key design choice is that the filename becomes the slug and primary key. That is simple, predictable, and usually exactly what you want for content.

Example Markdown record

Each file can contain frontmatter plus body content:

---
title: Building a Blog with Flat Files
published: true
date: 2024-03-15
tags: [laravel, markdown]
---

Your Markdown content goes here...

That gives you a model with attributes like title, published, date, tags, content, and slug.

Query published posts with standard Eloquent style

This is where the package becomes attractive.

$posts = Post::where('published', true)
    ->orderBy('date', 'desc')
    ->get();

That reads like completely standard Laravel code, which is the point.

In a real application, this could drive a blog index page without introducing a posts table at all.

Find a post by slug

Because the filename becomes the slug, lookup is straightforward.

$post = Post::find('hello-world');

Or through an explicit query:

$post = Post::where('slug', 'hello-world')->first();

That is especially nice for content routes because the slug is already stable and filesystem-backed.

Filter by tags

The README shows whereContains, which is one of the most useful examples because tags are a common array field in flat-file content.

$laravelPosts = Post::whereContains('tags', 'laravel')->get();

This is the kind of query you need in a real blog, docs site, or changelog archive.

You can imagine using it for topic pages:

$releaseNotes = Post::whereContains('tags', 'release-notes')
    ->where('published', true)
    ->orderBy('date', 'desc')
    ->get();

Search across fields

The package also supports common search-like queries.

For a simple title match:

$introPosts = Post::whereLike('title', '%hello%')->get();

Or for a broader search across title and content:

$results = Post::whereAny(['title', 'content'], 'like', '%flat-file%')->get();

That is a strong practical feature for content-heavy apps where you want lightweight internal search behavior without building a full search stack immediately.

Render Markdown content in a Blade view

The README includes a standard rendering loop, and it maps directly to the most common Laravel use case.

@foreach($posts as $post)
    <article>
        <h2>{{ $post->title }}</h2>
        <time>{{ $post->date }}</time>
        <div>{!! Str::markdown($post->content) !!}</div>
    </article>
@endforeach

That gives you a genuinely simple publishing system:

  • files live in version control
  • content is editable in a normal editor
  • rendering stays in Laravel
  • querying still feels like Eloquent

Example 2: JSON-backed records for lightweight structured data

Markdown is the obvious content use case, but JSON support is arguably just as practical.

Think about data that is structured but not large enough or relational enough to justify a table:

  • team directories
  • feature flags for internal tools
  • static partner metadata
  • speaker profiles for an event site
  • configuration-like content owned by developers

Define a JSON-backed model

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

#[Driver('json')]
#[ContentPath('content/team')]
class TeamMember extends Model
{
    use Paper;
}

Example JSON file

{
  "name": "Jacob Jorgensen",
  "role": "Developer",
  "github": "jacobjoergensen"
}

Query JSON-backed records

$team = TeamMember::all();

$developers = TeamMember::where('role', 'Developer')->get();

This is a compelling fit for sections of an application that want model semantics without database migrations.

For example, a team page could stay fully file-driven while still supporting filtering or sorting:

$engineeringTeam = TeamMember::where('role', 'Developer')
    ->orderBy('name')
    ->get();

That is much nicer than manually loading a directory and filtering arrays by hand.

Example 3: understanding filenames, slugs, and URLs

Laravel Paper makes a strong opinionated choice: the filename without extension becomes the slug.

For example:

content/posts/
├── hello-world.md
├── my-second-post.md
└── draft-post.md

These become:

  • hello-world
  • my-second-post
  • draft-post

That is a useful convention because it keeps content routing and storage aligned.

Query by slug

$post = Post::find('hello-world');

Sometimes the URL should differ from the filename. The README suggests adding a frontmatter field and routing off that instead.

---
title: Hello World
permalink: /blog/2024/hello-world
---

Then you can query or route on permalink instead of the slug if that better fits your public URL design.

That is a practical compromise. The filesystem name remains the internal key, but the content can still expose a different external URL.

Example 4: writing, updating, and deleting files through Eloquent

This is where the package stops being just a content reader and becomes a real model driver.

Create a new Markdown-backed record

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

That writes the content to disk through the model.

Update an existing record

$post->title = 'Updated title';
$post->save();

Delete a record

$post->delete();

This is important because it means Laravel Paper is not just a query adapter. It supports the full write cycle for workflows like:

  • simple CMS screens
  • internal editorial tools
  • static content administration
  • code-generated content

Use familiar Eloquent creation helpers

The package also supports the more common model creation methods Laravel developers expect:

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

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

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

That makes it much easier to use this package inside existing Laravel patterns instead of treating it as a special subsystem.

Save quietly or refresh from disk

There is also support for model-event-aware workflows:

$post->saveQuietly();
$post->deleteQuietly();

And for reloading from disk:

$fresh = $post->fresh();
$post->refresh();

That matters when content is being modified by multiple processes, generators, or editors.

Example 5: paginate flat-file content

Pagination is one of the most useful features to preserve when moving away from a database, because archive pages and listing screens still need it.

$posts = Post::paginate(15);

Or if you do not need total counts:

$posts = Post::simplePaginate(15);

simplePaginate is especially sensible for large directories, where counting everything may be more expensive than necessary.

A realistic example would be a docs or changelog archive:

$entries = Post::where('published', true)
    ->orderBy('date', 'desc')
    ->simplePaginate(20);

That keeps a very normal Laravel controller flow intact even though the data source is the filesystem.

Example 6: relationships between flat-file models

Relationships are where many flat-file solutions start to feel awkward. Laravel Paper addresses that with belongsToPaper and hasManyPaper.

Define an author relationship

class Post extends Model
{
    use Paper;

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

class Author extends Model
{
    use Paper;

    public function posts()
    {
        return $this->hasManyPaper(Post::class);
    }
}
$post = Post::find('hello-world');
$author = $post->author();

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

The README notes that these should be called as methods, not properties, and that the default foreign key is {model}_slug, such as author_slug.

That is an important detail, because it preserves a familiar relationship model without pretending flat files behave exactly like SQL relations.

A practical content example

Imagine a small editorial system:

  • content/authors/jane-doe.md
  • content/posts/hello-world.md

The post file could include:

---
title: Hello World
author_slug: jane-doe
published: true
---

Then the post can resolve its author, and the author can resolve their posts, all inside a Laravel model workflow.

That is enough for many content sites, documentation systems, portfolio sites, or editorial microsites.

Example 7: validation with PaperRule

Validation matters if you want real write workflows instead of just readonly content.

Laravel Paper includes PaperRule helpers for uniqueness and existence checks.

use JacobJoergensen\LaravelPaper\Rules\PaperRule;

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

This is important if you are building:

  • a simple admin interface
  • a content editing tool
  • an internal publishing workflow
  • import tooling that writes records to disk

You can also ignore the current record during updates:

PaperRule::unique(Post::class)->ignore($post->slug);

That is exactly the kind of small but necessary feature that determines whether a package is practical in day-to-day Laravel work.

Example 8: building a minimal flat-file CMS inside Laravel

The package is especially compelling when you do not want a separate CMS product, but you still want Laravel-managed content.

Here is a realistic setup:

Model

#[Driver('markdown')]
#[ContentPath('content/pages')]
class Page extends Model
{
    use Paper;
}

Content file

---
title: About
slug: about
published: true
nav_title: About Us
---

This is the about page.

Controller

class PageController
{
    public function show(string $slug)
    {
        $page = Page::where('slug', $slug)
            ->where('published', true)
            ->firstOrFail();

        return view('pages.show', compact('page'));
    }
}
$navigation = Page::where('published', true)
    ->orderBy('nav_title')
    ->get();

This is not theoretical. A lot of company sites, brochure sites, product docs, and internal dashboards need exactly this level of content management and no more.

Example 9: custom drivers for other formats

Markdown and JSON will cover a lot of cases, but the package also supports custom drivers. That matters because real projects often already have content in YAML, TOML, or some other internal format.

The README shows the contract shape:

use JacobJoergensen\LaravelPaper\Contracts\DriverContract;
use JacobJoergensen\LaravelPaper\Drivers\DriverRegistry;

final class YamlDriver implements DriverContract
{
    public function extensions(): array
    {
        return ['yaml', 'yml'];
    }

    public function parse(string $filepath): array
    {
        // return the file's data as an array
    }

    public function serialize(array $data): string
    {
        // return the file contents to write
    }
}

Then register it in a service provider:

public function boot(): void
{
    app(DriverRegistry::class)->register('yaml', YamlDriver::class);
}

And use it on a model:

#[Driver('yaml')]
#[ContentPath('content/products')]
class ProductSheet extends Model
{
    use Paper;
}

This is the kind of extensibility that makes the package more than a niche Markdown helper.

Where Laravel Paper fits best

Laravel Paper looks especially strong for these cases:

  • blogs and changelogs
  • documentation sections
  • small marketing sites inside Laravel
  • team directories and staff pages
  • low-volume CMS-like content
  • developer-owned structured content in version control
  • prototypes and internal tools that need persistence without database setup

It is particularly attractive when content should live near code, be easy to diff in Git, and not require a separate data store.

Tradeoffs to keep in mind

Flat files are not a universal replacement for a database, and the package is best when used with that in mind.

  • Large write-heavy workloads still belong in a database.
  • Concurrent writes and filesystem coordination need more care than SQL-backed systems.
  • Complex querying across large datasets will still be more limited than a relational database.
  • Relationship handling is useful, but it is not a substitute for full relational integrity.

Those are not flaws in the package so much as the normal boundaries of the flat-file approach.

Conclusion

Laravel Paper is compelling because it does not ask Laravel developers to learn a new content model. It keeps the mental model they already use every day: models, queries, validation, pagination, relationships, create, update, delete.

The difference is simply that the backing store is the filesystem.

That makes it a strong option for a specific but common class of Laravel projects: the ones where a database feels like overkill, but hand-rolled file loading feels too primitive. Laravel Paper sits neatly in that middle ground, and that is exactly why it is interesting.