dejodev/page-blocks
A Laravel package for creating and managing reusable page blocks, letting you compose dynamic page content from configurable block types and render them easily in Blade views without hardcoding layouts.
This package is a small, single-purpose Filament add-on for editable page content: rather than hard-coding marketing pages, you give editors a stack of "blocks" (hero, rich text, image + text, CTA, etc.) they can reorder and fill in. It is a low-traffic package (0 stars, 0 dependents), so treat the source as the documentation and budget time to read it before promising it to a client.
Install and publish
composer require dejodev/page-blocks
php artisan vendor:publish --provider="DejoDev\PageBlocks\PageBlocksServiceProvider"
php artisan migrate
Publish both config and migrations in one go if the provider tags them; if vendor:publish lists tags, prefer the explicit --tag= form so you know exactly what landed in your app.
Register the plugin with your panel
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->plugin(\DejoDev\PageBlocks\PageBlocksPlugin::make());
}
Where to look first, in this order
config/page-blocks.php — this is almost always where block types are declared, along with the model/table names and any disk or route prefix. This file is your real API surface.longText column that holds the block payload. Knowing the exact shape ([{type, data}] vs {type: data}) saves you hours later when you need to query or migrate it.src/ — check whether it ships its own PageResource or expects you to attach blocks to an existing model. This decides whether you adopt the package's model or wrap your own.resources/views folder shows you which view name is used per block type. That naming convention is your extension point.First use case to validate with
Build one throwaway page: a hero block (heading, subheading, background image), a rich_text block, and a cta block with a link. Create it in the panel, render it on a public route, and confirm that reordering the blocks in the admin changes the public output. If that loop works end to end, the rest of the package is variations on a theme. If it doesn't, you've found the package's ceiling before you've written any client code.
Day to day, most work is adding block types. Keep them declarative and colocated — one class or one config array per block, containing: a key, a label, an icon, a Filament form schema, and a Blade view. Resist the temptation to add per-block PHP logic; if a block needs behaviour, that behaviour usually belongs in the renderer or a view composer, not in the block definition.
// config/page-blocks.php — illustrative shape; confirm key names in the source
'blocks' => [
'hero' => [
'label' => 'Hero',
'icon' => 'heroicon-o-photo',
'view' => 'blocks.hero',
'schema' => [
TextInput::make('heading')->required(),
Textarea::make('subheading'),
FileUpload::make('image')->image()->directory('pages/hero'),
TextInput::make('cta_label'),
TextInput::make('cta_url')->url(),
],
],
// ...
],
Do not scatter @foreach ($page->blocks as $block) across a dozen views. Wrap it once and reuse:
// app/Support/BlockRenderer.php
public function render(Page $page): string
{
return Cache::remember(
"page:{$page->id}:blocks:{$page->updated_at?->timestamp}",
now()->addHours(6),
fn () => $page->blocks
->map(fn ($block) => view("blocks.{$block['type']}", $block['data'] ?? [])->render())
->implode(''),
);
}
Keying the cache on updated_at (rather than flushing manually on save) means a model observer is optional, and stale content self-heals. If the package ships its own renderer, call it from inside the closure instead of duplicating the loop.
// routes/web.php
Route::get('/{slug}', PageController::class)->where('slug', '.*');
// PageController
public function __invoke(string $slug, BlockRenderer $renderer)
{
$page = Page::where('slug', $slug)->firstOrFail();
return view('page', ['page' => $page, 'html' => $renderer->render($page)]);
}
Echoing pre-rendered HTML with {!! $html !!} keeps the Blade layout simple and makes the cache boundary obvious.
Block JSON in a local database is worthless to a teammate. Ship a seeder that creates the three or four canonical pages (home, about, contact) with a fixed block order, and use that same seeder in CI for rendering tests. This also gives you a canary: if a block key is renamed in config, the seeder fails loudly rather than silently rendering empty sections.
Given the dependency risk of a 0-star package, put a thin application wrapper around the two things you'll actually touch — the page model and the renderer — rather than calling package classes directly from controllers, seeders and tests. If the package is abandoned or you outgrow it (you will want block-level versioning or a media library integration eventually), you swap the internals of two classes and leave the rest of the app untouched.
Grant the page resource to editors, not admins, and pair it with a preview. If the package doesn't ship one, add a "Preview" action that hits the public route in a new tab; content editors judge blocks visually, and the cost of not having preview is a stream of "it looks wrong live" tickets. Also give the reorder handle a clear affordance — drag-and-drop reordering is the single most-used interaction in these builders.
Zero stars, zero dependents. There is no community to lean on: no Stack Overflow answers, no third-party tutorials, no issue history to search. Read every file in src/ on day one — it's a small package and this is genuinely a 30-minute job. Pin the version in composer.json and check what changed before upgrading, because there's no deprecation policy to rely on.
Filament major-version compatibility. Small Filament plugins break hard across v3 → v4 (form component namespaces, Filament\Forms\Components vs schema API, panel plugin contract). Confirm which major the package targets before installing, and if your panel is on the other version, walk away rather than patching the vendor tree.
JSON column drift. Block payloads live as JSON. That means no schema enforcement: adding ->required() to a field in config does nothing for rows already in the database. When you change a block's shape, write a one-off migration that backfills the JSON, and keep your renderer defensive:
{{-- blocks/hero.blade.php --}}
@if(!empty($image))
<img src="{{ Storage::disk('public')->url($image) }}" alt="{{ $heading ?? '' }}">
@endif
Every $variable in a block view should be treated as possibly-missing, because old rows will be missing it.
Unknown block types render as errors, not nothing. If an editor saved a block and you then delete or rename it in config, the view resolution throws View [blocks.old-type] not found. Guard the loop with View::exists("blocks.{$type}") and log the miss — silent skipping hides real bugs; a hard 500 on your homepage doesn't.
File uploads leak. FileUpload blocks store paths; deleting a page or removing a block does not delete the underlying file unless the package explicitly implements it. Check for a deleting model hook, and if absent, add one that collects image paths from the JSON and calls Storage::disk(...)->delete(...). Do this before launch, not after — orphaned media is tedious to reconcile later.
Rich text is user input. If a block type uses RichEditor or a TinyMCE field, sanitise on output (HTMLPurifier or Filament's built-in sanitisation, depending on version) or restrict the field to trusted roles. {!! !!} on un-sanitised editor content is a stored-XSS vector, and it's the easiest mistake to make with any block builder.
Reordering can renumber your data unexpectedly. Some implementations write an explicit sort column, others rely on array order inside the JSON. Know which one yours does before you write any query that filters blocks, because "get all pages with a CTA block" is trivial in one design and requires JSON path queries in the other. On MySQL 5.7+/Postgres, whereJsonContains handles the array case; on SQLite (common in test suites) support is uneven — assert that path in a test.
Cache invalidation. Any page-level caching keyed on updated_at also needs invalidation when config changes (new block view, changed view name). Add a deploy step that clears the page cache tag, or version the cache key with a constant you bump on release.
Extension points to prefer. Publish and override the Blade views (safest — no vendor edits), override config for the block registry (safe), and only subclass package classes if the source is clearly designed for it (look for interfaces, protected methods and a service container binding). If block registration happens in a service provider with no published config, subclassing the provider is cleaner than editing config at runtime.
Verify before committing. Concrete things to confirm in the repo, since they determine your integration cost: (1) does it ship its own Page model and migration, or attach blocks to yours; (2) is the block list config-driven or hard-coded in a Filament Builder; (3) is there a renderer at all, or only the admin side; (4) does it support locale-scoped content; (5) what does composer.json constrain for filament/filament and php. If (3) is missing, budget an afternoon to write the renderer — the admin half is the harder part, so that's still a reasonable trade.
How can I help you explore Laravel packages today?