webuni/front-matter
webuni/front-matter is a PHP library for extracting and parsing front matter from files. It separates metadata from content, supports YAML-style headers, and makes it easy to read Markdown front matter in PHP applications. Simple, lightweight, and framework-agnostic.
Install
composer require webuni/front-matter
composer show webuni/front-matter # confirm the major version you got
Where to look first
Open vendor/webuni/front-matter/src/ and skim, in this order: FrontMatterInterface.php, DocumentInterface.php, FrontMatter.php. The whole surface is parse(string): DocumentInterface and usually parseFile(string): DocumentInterface; the returned document exposes getData() (the YAML metadata as an array) and getContent() (the body). Type-hint FrontMatterInterface everywhere rather than the concrete class — 1.x and 2.x differ on whether the class is FrontMatter or YamlFrontMatter, but the interface is stable.
Bind it once
// app/Providers/AppServiceProvider.php
use Webuni\FrontMatter\FrontMatterInterface;
use Webuni\FrontMatter\FrontMatter;
public function register(): void
{
// Use a closure if your version's constructor expects a YAML parser argument.
$this->app->singleton(FrontMatterInterface::class, fn () => new FrontMatter());
}
First use case: an artisan import command that turns storage/app/content/*.md into Post rows — front matter becomes columns, body becomes the markdown column.
$document = app(FrontMatterInterface::class)->parse($markdown);
$document->getData(); // ['title' => 'Hello', 'tags' => ['php', 'laravel']]
$document->getContent(); // "Hello world\n"
Import command with validation (the bread-and-butter pattern)
class ImportContent extends Command
{
protected $signature = 'content:import {dir=content}';
public function handle(FrontMatterInterface $frontMatter): int
{
foreach (File::files(storage_path('app/'.$this->argument('dir'))) as $file) {
$document = $frontMatter->parseFile($file->getPathname());
$data = $document->getData();
Validator::make($data, [
'title' => 'required|string|max:200',
'slug' => 'nullable|string|alpha_dash',
'published_at' => 'nullable|date',
'tags' => 'nullable|array',
'tags.*' => 'string',
])->validate();
Post::updateOrCreate(
['slug' => $data['slug'] ?? Str::slug($data['title'])],
[
'title' => $data['title'],
'body' => $document->getContent(),
'meta' => Arr::except($data, ['title', 'slug', 'published_at']),
'published_at' => isset($data['published_at'])
? Carbon::parse($data['published_at'])
: null,
]
);
}
return self::SUCCESS;
}
}
Treat getData() as untrusted input. It is author-supplied YAML; validate it exactly like a request payload, and never extract() it into local scope.
Render pipeline
$document = $frontMatter->parse(File::get($path));
return view('post', [
'meta' => $document->getData(),
'html' => Str::markdown($document->getContent(), ['html_input' => 'escape']),
]);
Keep parsing and rendering as two steps — that way the same parsed document feeds both the <head> (title, description, og: tags) and the body.
Cache the parsed result, not the file read
$payload = Cache::remember(
"content:{$slug}:".File::lastModified($path),
now()->addHour(),
fn () => tap($frontMatter->parseFile($path), null)
->pipe(fn ($d) => ['data' => $d->getData(), 'content' => $d->getContent()])
);
Cache the plain ['data' => ..., 'content' => ...] array rather than the document object — it survives serialization drivers cleanly and is obvious in redis-cli.
Custom delimiters / other formats
Implement FrontMatterInterface yourself (TOML +++ blocks, JSON header for API-driven content) and swap the container binding. Alternatively decorate: a class that takes the package parser, strips a leading comment block, and delegates. Consumers never change.
Testing
Storage::fake('content') + $this->artisan('content:import') + assertDatabaseHas. For the parser itself use assertSame on getContent() — whitespace differences are the whole bug surface here, and assertEquals will hide them.
\r\n before --- makes the parser silently return the whole file as body and [] as data. Normalize on read: $raw = ltrim($raw, "\xEF\xBB\xBF");--- horizontal rule later in the markdown body is safe — but a document whose body starts with --- will be misread as an opening fence with no metadata.---. Never compare rendered HTML in tests; compare getContent() with assertSame and pin the exact expected string.---\n---\n) legitimately yields [] plus the full body — same as no front matter at all. If you need to distinguish "no metadata" from "declared empty", check the raw file yourself.published_at: 2025-02-08 may arrive as a DateTimeImmutable (Symfony YAML PARSE_DATETIME) or a string, depending on the parser wired in; yes/no/on/off become booleans; 1.10 becomes the float 1.1; 0123 becomes octal-ish ints. If a column is a string, cast defensively — (string) on a bool is "1", not "yes".title: Laravel: The Good Parts is a YAML parse error. Same for values starting with @, `, *, !, %, {. This is the #1 author error in a content repo; surface the parser exception with the filename attached.parseFile() raises on a missing file; malformed YAML raises from src/Exception (or the underlying parser). Wrap per-file work in try/catch inside the import loop so one bad post doesn't kill the run, and log with $file->getFilename() in context.Str::markdown() knows nothing about front matter. Strip with this package first, or you'll render --- as an <hr> and dump your metadata into the page. (If you'd rather do it in one pass, league/commonmark's FrontMatterExtension bundles both — but then you lose the clean data/body split this package gives you.)draft: true, internal author_email, reviewer: notes all end up in getData(). Filter explicitly before passing metadata to the view; don't @foreach ($meta as $k => $v) into <meta> tags.artisan content:build step that pre-renders into a pages table or cache keys rather than parsing at request time.FrontMatterInterface in a logging decorator to find which files are parsed on which routes, or in a memoizing decorator keyed by path+mtime, without touching call sites.How can I help you explore Laravel packages today?