jacobjoergensen / laravel-paper
A flat-file Eloquent driver for modern Laravel.
Requires
- php: ^8.4
- illuminate/database: ^12.0|^13.0
- illuminate/filesystem: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- spatie/yaml-front-matter: ^2.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.1
- phpstan/phpstan: ^2.1
README
Laravel-Paper is a Laravel package that adds flat-file driver support for Eloquent. It supports Markdown and JSON files and works with Laravel 12+ on PHP 8.4+.
Why Laravel-Paper?
Two PHP 8 attributes and a trait. No custom database connection, no schema, your flat files use Eloquent's familiar query API.
Get Started
To get started run the following command in your project
composer require jacobjoergensen/laravel-paper
Quick Example
Put your Markdown files in content/posts/:
--- title: Building a Blog with Flat Files published: true date: 2024-03-15 tags: [laravel, markdown] --- Your Markdown content goes here...
Create a new model:
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; }
Query it like any other Eloquent model:
// Get all published posts $posts = Post::where('published', true) ->orderBy('date', 'desc') ->get(); // Find by slug $post = Post::where('slug', 'flat-file-blog')->first(); // Filter by tag (whereContains checks membership of an array field) $laravelPosts = Post::whereContains('tags', 'laravel')->get(); // Match a substring in a string field $intro = Post::whereLike('title', '%hello%')->get(); // Search a value across multiple columns $results = Post::whereAny(['title', 'content'], 'like', '%flat-file%')->get();
Use it in your views:
@foreach($posts as $post) <article> <h2>{{ $post->title }}</h2> <time>{{ $post->date }}</time> <div>{!! Str::markdown($post->content) !!}</div> </article> @endforeach
JSON Files
Works the same way with JSON:
{
"name": "Jacob Jørgensen",
"role": "Developer",
"github": "jacobjoergensen"
}
#[Driver('json')] #[ContentPath('content/team')] class TeamMember extends Model { use Paper; }
$team = TeamMember::all(); $devs = TeamMember::where('role', 'Developer')->get();
Custom Drivers
Markdown and JSON ship by default. To support another format, implement DriverContract and register it in a service provider:
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 } }
public function boot(): void { app(DriverRegistry::class)->register('yaml', YamlDriver::class); }
Then point a model at it with #[Driver('yaml')].
File Naming
The filename (without extension) becomes the model's slug:
content/posts/
├── hello-world.md → slug: "hello-world"
├── my-second-post.md → slug: "my-second-post"
└── draft-post.md → slug: "draft-post"
$post = Post::find('hello-world');
To change a slug, rename the file. For a URL that differs from the filename, add a frontmatter field and route on that instead:
--- title: Hello World permalink: /blog/2024/hello-world ---
Writing
Paper models save and delete files using the standard Eloquent API.
$post = new Post(); $post->slug = 'hello-world'; $post->title = 'Hello World'; $post->content = 'My first post.'; $post->save(); $post->title = 'Updated title'; $post->save(); $post->delete();
Save and delete fire the usual model events.
Relationships
For relationships, use belongsToPaper and hasManyPaper:
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();
Call these as methods, not properties. 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)], ]);
To skip the current record on update:
PaperRule::unique(Post::class)->ignore($post->slug);
License
This project is licensed under the MIT License - see the LICENSE file for details.