jacobjoergensen/laravel-paper
Laravel Paper adds flat-file drivers to Eloquent for Laravel 12+ (PHP 8.4+). Point a model to a content directory and query Markdown or JSON files with familiar Eloquent APIs—no database, schema, or custom connection. Uses attributes + a trait.
Installation:
composer require jacobjoergensen/laravel-paper
No additional configuration is needed beyond Laravel 12+ and PHP 8.4+.
First Model:
Create a model in app/Models with the required attributes:
use JacobJoergensen\LaravelPaper\Attributes\{ContentPath, Driver};
use JacobJoergensen\LaravelPaper\Paper;
#[Driver('markdown')] // or 'json'
#[ContentPath('content/posts')]
class Post extends Model
{
use Paper;
}
storage/app).markdown or json).First File:
Create a file in storage/app/content/posts/hello-world.md:
---
title: Hello World
---
Content here...
The filename (hello-world) becomes the primary key.
First Query:
$post = Post::find('hello-world'); // Returns Post model
$posts = Post::all(); // Returns Collection of Post models
[Driver] and [ContentPath]—these define the core behavior.storage/app/{content-path}.--- key: value ---).Replace a database-backed blog with flat files:
storage/app/content/posts/ as .md files.Post::latest()->take(5) to fetch recent posts in a controller.@foreach($posts as $post) {{ $post->title }}: {{ $post->content }} @endforeach.$post = new Post(['title' => 'New Post']);
$post->save(); // Writes to `storage/app/content/posts/{slug}.md`
Str::slug($title) if not set.save().$post->delete() removes the file.$post = Post::find('slug') or Post::where('title', 'like', '%Laravel%').content attribute (e.g., $post->content).---
title: Guide
excerpt: Short preview
---
# Full Content
...
storage/app/content/posts/guide.json):
{
"title": "Guide",
"content": "Full content...",
"excerpt": "Preview"
}
Post::where('published', true)->get();
Post::where('tags', 'contains', 'laravel')->get();
Post::orderBy('date', 'desc')->take(3);
where, orWhere, orderBy, limit, offset, etc.Post::observe(PostObserver::class);
class PostObserver {
public function saved(Post $post) {
// Trigger after save (e.g., generate sitemap)
}
}
class Post extends Model {
public function comments() {
return $this->hasMany(Comment::class);
}
}
Paper trait.getRouteKeyName() or set slug explicitly:
$post->slug = 'custom-slug';
$post->save();
storage/app/content is writable:
chmod -R 775 storage/app/content
class Post extends Model {
public function shouldBeSearchable() {
return true;
}
}
scout in config/scout.php to use a local driver.Paper models in Nova resources.storage/app/content to object storage (e.g., S3) for scalability:
#[ContentPath('s3://my-bucket/content/posts')]
class Post extends Model { ... }
league/flysystem-s3v3 and configuration in config/filesystems.php.public function updatedTitle() {
$this->save(); // Persists changes to file
}
protected static function booted() {
static::saving(function ($model) {
$model->validateOnly(['title', 'content']);
});
}
PaperTestCase (if provided) or mock the filesystem:
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostTest extends TestCase {
use RefreshDatabase;
public function test_post_creation() {
$post = new Post(['title' => 'Test']);
$post->save();
$this->assertFileExists(storage_path('app/content/posts/test.md'));
}
}
FileNotFoundException or PermissionDeniedException.storage/app/content is writable:
chmod -R 775 storage/app/content
storage/log/laravel.log for filesystem errors.Symfony\Component\Yaml\Exception\ParseException.---
title: "Post with 'quotes'"
---
JsonException.{} // Valid
[] // Invalid
Post::find('Hello-World') fails if the file is hello-world.md.Str::slug()).Artisan::call('cache:clear');
Model::unguard() in tests or disable caching for development:
config(['paper.cache' => false]);
with() cautiously or handle missing files:
$post = Post::with('comments')->find('slug');
if (!$post->comments->isEmpty()) { ... }
content attribute for body and store metadata separately.config(['paper.debug' => true])How can I help you explore Laravel packages today?