pvtl/voyager-page-blocks
A Laravel package that adds reusable page blocks to Voyager, letting you compose pages from modular content sections managed in the admin panel. Includes block types, page associations, and flexible rendering for faster, more maintainable site building.
Prerequisites
php artisan voyager:install has been run, admin login works).composer require pvtl/voyager-page-blocks may still resolve from Packagist, but pin it and be ready to switch to a VCS path repo or a fork if you need patches.Install in four steps
composer require pvtl/voyager-page-blocks
# Publish migrations (and any config/views the provider offers)
php artisan vendor:publish --provider="Pvtl\VoyagerPageBlocks\PageBlocksServiceProvider"
php artisan migrate
# Voyager admin: check the sidebar for the new "Page Blocks" BREAD
Confirm the exact provider namespace, publish tags, and config keys against the repo README before running — with an archived package you only get one shot at reading the docs, and they may not match your Voyager version.
Where to look first
database/migrations/ after publishing — this tells you the real schema (columns, ordering field, whether it's polymorphic or page-id based). Everything else follows from this./admin/page-blocks) — this is where editors create blocks and attach them.PageBlock or similar) — read its relationships and any render()/accessor methods before writing your own Blade.config/voyager.php — to understand how the module hooks into Voyager's menu and routes.First use case: a homepage with a hero and a text block
hero, one of type text.@foreach ($page->blocks()->orderBy('order')->get() as $block)
@includeIf("blocks.{$block->type}", ['block' => $block])
@endforeach
resources/views/blocks/hero.blade.php and blocks/text.blade.php. The @includeIf means an unknown block type silently renders nothing rather than throwing.That's the whole mental model: blocks are rows, types are Blade partials, order is a column.
1. Type-to-partial convention (the core pattern)
Keep a one-to-one mapping between the type column and a Blade partial under resources/views/blocks/. Add a fallback partial for unknowns so editors can't 500 a page:
@includeFirst(["blocks.{$block->type}", 'blocks.fallback'], ['block' => $block])
This lets non-developers create blocks in admin without touching code, as long as you've shipped the partial.
2. Centralise rendering in a component or helper
Don't scatter @foreach loops through views. Wrap it once:
{{-- resources/views/components/page-blocks.blade.php --}}
@props(['page'])
@foreach ($page->blocks as $block)
@includeFirst(["blocks.{$block->type}", 'blocks.fallback'], ['block' => $block])
@endforeach
<x-page-blocks :page="$page" />
This is also the single place to add caching, sanitisation, and error handling later.
3. Polymorphic/reusable blocks
If the schema uses a blockable morph (check the migration), you can attach the same block concept to products, articles, landing pages — not just pages. That's the highest-leverage use of the package: one editorial UI, many content types.
// In each model that owns blocks
public function blocks(): MorphMany
{
return $this->morphMany(PageBlock::class, 'blockable')->orderBy('order');
}
If the schema is hard-wired to a page_id, prefer adding a morph column yourself over duplicating the table.
4. Ordering UI
Voyager's relationship BREAD gives you a drag-and-drop or multi-select, but it doesn't always persist an order column. Two reliable options:
order to the relationship pivot/table and sort by it in the accessor; provide a small custom Voyager form field or reorder action.order_column in the model's $sortable).Pick one and document it for editors — otherwise "why did my blocks reorder?" becomes a support ticket.
5. Caching
Block rendering is a prime cache target. Key on the parent model and its updated_at:
public function renderedBlocks(): string
{
return Cache::remember(
"page-blocks:{$this->getMorphClass()}:{$this->id}:{$this->updated_at}",
now()->addHours(6),
fn () => view('partials.blocks', ['blocks' => $this->blocks])->render()
);
}
Bust it in the block model's saved and deleted events so editors see changes immediately:
static::saved(fn ($block) => Cache::forget(/* keys for the parent */));
6. Seeding for parity across environments
Blocks are content, and content that only exists in the production admin UI will bite you in staging. Seed the standard block set (hero, CTA, footer text) per environment so tests and local dev match production shape.
7. Block-level validation
If block content is stored as JSON or HTML, validate on save in a Voyager model observer, not at render time. A malformed block should be rejected by the editor, not caught by a 500 on the front end.
Archived status is the headline
Last release mid-2020, repo archived. Treat it as vendored code you own, not a dependency you upgrade. Before committing:
composer.json constraints against your Laravel/PHP versions.str_*, array_*, Arr:: usage) and Laravel facade changes if you're on 9+.app/ and drop the dependency entirely. The package's value is the editorial UX pattern, not the code.Voyager BREAD permissions
The single most common "it's installed but nothing appears" cause: the BREAD exists but roles lack browse/read/edit permissions. Go to Tools → BREAD → Page Blocks → edit → permissions and grant access explicitly. Also confirm the menu item landed in admin (check config/voyager.php menu).
Migration collisions
Publishing may produce a create_page_blocks_table migration that clashes with an existing table of the same name (especially if you've tried a similar package before). Read the published migration and rename or guard with Schema::hasTable() before running.
Relationship vs. column attachment
The gotcha that wastes the most time: the package may attach blocks via a Voyager relationship (pivot/morph) and also have a legacy page_id column. Decide which your queries use and stick to it — mixing both produces duplicate blocks in one view and missing blocks in another.
N+1 on blocks and media
Blocks that reference Voyager media ($block->image, file attachments) will hammer the DB without eager loading:
$page->load('blocks.media'); // adjust relation name to the actual schema
And if you're caching, cache the rendered HTML, not the Eloquent collection — serialised models with media relations get stale and large.
Unknown block types should not 500
Always use @includeIf / @includeFirst with a fallback. When an editor picks a type you haven't built a partial for, log it and render nothing. Bonus: this lets you delete a block type from the codebase without breaking existing pages.
XSS on rich-text blocks
{!! $block->content !!} for a text/html block is standard, but only safe if the editor role is trusted. If lower-privilege users can edit blocks, run the content through a sanitiser (HTMLPurifier, mews/purifier) on save.
Voyager storage driver
Images uploaded via the block form go through Voyager's storage driver (local or S3). Switching drivers later breaks stored paths. Centralise a blockImageUrl($block) helper rather than calling Voyager::image() inline everywhere, so a future driver change is one edit.
Order column quirks
If blocks appear unsorted, check:
order column exists and is non-nullable with a default.orderBy('order') — a relationship defined without it will return insertion order.Debugging checklist
| Symptom | Likely cause |
|---|---|
| BREAD missing from admin | Permissions on the BREAD, or menu cache (php artisan view:clear) |
| Blocks render but out of order | Missing orderBy, or null order values |
| White page / 500 on front end | Unknown type, missing partial — add fallback |
| Blocks duplicate | Both page_id and polymorphic relation queried |
| Editor changes not visible | Cached render, key not busted on save |
| File/image paths broken | Voyager storage driver changed |
Extension points worth using
render() method or accessor on the block model — override in an app-level subclass to centralise formatting.saved, deleted) on the block model — for cache busting and search index updates.type (select from registered partials) — prevents editors choosing types that don't exist.If you're greenfield, reconsider
The pattern (typed content blocks + Voyager BREAD + Blade partials) is sound and worth copying. The package itself is a 2020 artefact. On a new project, implement the same three-table/three-file pattern yourself in an afternoon and keep your dependency graph clean.
How can I help you explore Laravel packages today?