anomaly/blocks-module
The Anomaly Blocks module brings reusable content blocks to your Laravel-based Anomaly CMS. Create, organize, and render modular content chunks with custom fields, making it simple to manage editable regions across pages, layouts, and templates without duplicating content.
This is a PyroCMS / Streams Platform addon, not a standalone package. It only makes sense inside an Anomaly streams-platform application (PyroCMS 3.x). Do not try to require it into a vanilla Laravel app and expect a service provider to "just work."
Minimal setup:
composer require anomaly/blocks-module
php artisan addon:install anomaly.blocks_module
php artisan addon:publish anomaly.blocks_module
Then confirm the stream migration ran — you should see blocks_blocks and the block-type-related tables after:
php artisan migrate
php artisan streams:compile
Where to look first, in this order:
src/BlocksModuleServiceProvider.php — what gets registered, which plugins/listeners are bound.src/Block/ — BlockModel, BlockRepository, BlockRouter, and the BlockExtension base class. This is the real API surface.src/BlockType/ — the built-in block types (HTML, Markdown, etc.) and BlockTypeExtension. Copy one of these as a template.src/Blocks/ — the form/table builders used by the admin UI. Useful when you need to add fields to the block form.resources/views/ and src/Http/Controller/Admin/ — to understand what the admin screens actually render.First use case: create an HTML block in the control panel under Content → Blocks, give it a slug like footer-promo, then render it in a layout:
{{ block('footer-promo') }}
If that returns nothing, the module isn't loaded, the block isn't published, or the cache is stale — see the Gotchas section.
Define a custom block type. Extend Anomaly\BlocksModule\Block\BlockExtension, and register the extension in a service provider (protected $extensions = [...] or via the addon's providers). Give it a unique $slug and $name:
namespace Acme\Site\Block\Type;
use Anomaly\BlocksModule\Block\BlockExtension;
class HeroBannerExtension extends BlockExtension
{
protected $slug = 'hero_banner';
protected $name = 'Hero Banner';
public function render()
{
return view('acme/site::blocks/hero', [
'heading' => $this->getBlock()->heading,
])->render();
}
}
The extension's form fields are declared the same way as any Streams form section — add a form() method returning a FormBuilder, or attach a BlockTypeFieldSeeder-style configuration. Keep the rendering logic in the extension, not in the Twig template, so the block is portable across themes.
Render blocks in Twig. The module ships a plugin exposing both a singular accessor and an area-based collection:
{# Single block by slug #}
{{ block('footer-promo') }}
{# All blocks assigned to an area, in display order #}
{% for item in blocks('homepage-sidebar') %}
<aside class="sidebar-widget">{{ item.render|raw }}</aside>
{% endfor %}
Prefer iterating blocks(area) over hard-coding individual slugs — it lets editors reorder and toggle blocks from the admin without a deploy.
Assign blocks to areas. Areas are declared per theme/layout, and the blocks field type lets an editor pick which blocks land in which area. The typical pattern is a blocks field on the page or layout stream with area configuration:
protected $fields = [
'sidebar' => [
'type' => 'anomaly.field_type.blocks',
'config' => [
'area' => 'sidebar',
],
],
];
Then in the layout: {% for block in page.sidebar %}{{ block.render|raw }}{% endfor %}.
Programmatic CRUD. For seeding or migrations, use the repository rather than BlockModel directly, so the Streams events fire:
use Anomaly\BlocksModule\Block\Contract\BlockRepositoryInterface;
$blocks = app(BlockRepositoryInterface::class);
$blocks->create([
'slug' => 'cookie-notice',
'type' => 'html',
'title' => 'Cookie Notice',
'area' => 'footer',
]);
Visibility and scheduling. Rather than writing custom logic per block type, use the standard block fields (publish_at, enabled, area assignment) and let the repository's query constraints filter. If you need per-block display rules (device, locale, user group), add a field type and a repository criterion instead of branching in every template.
Caching. Render-heavy blocks should cache themselves. The idiomatic pattern is to cache the rendered output keyed on the block ID and a modified timestamp, and bust it in the block's saved hook:
cache()->forget('block.'.$this->getId());
// in render()
return cache()->rememberForever('block.'.$this->getId(), function () {
return view('acme/site::blocks/hero', [...] )->render();
});
Alternatively, delegate caching to the Streams view layer or the page's own cache — but pick one layer, not both, or you'll get ghost content.
It is effectively unmaintained and low-adoption. Zero stars, zero dependents, score 1.48. Treat the source as the documentation. Pin the exact version in composer.json and read the composer.json of the tagged release to confirm which streams-platform major it was written against — version drift here is the number one cause of silent failures.
Caching will bite you first. Block output is cached at multiple levels (Streams view cache, the module's own rendering, and any full-page cache). After editing a block in the admin, always:
php artisan cache:clear
php artisan view:clear
If the content still doesn't change, check the blocks view namespace in config/view.php and whether the addon's publish step actually copied its views into resources/views/vendor/.
Areas must be declared before they work. A block assigned to an area that isn't registered anywhere will simply not appear. Grep for the blocks field type config and any theme-level area registration files — an area typo is indistinguishable from an empty result.
block() returns a rendered string; blocks() returns a collection of block instances. Mixing them up produces "call to a member function render on string" or silently empty output depending on version. Check src/Plugin/BlockPlugin.php to confirm the exact return type for your tagged version.
Raw output is required for HTML blocks. {{ block('x') }} in Twig will escape markup unless you apply |raw. That's a deliberate safety default, but it means block types that render markup should document it, and any block accepting editor input should be treated as trusted-author-only, not user-submitted content.
Custom block types need an extension registration, not just a class. A class in src/Block/Type/ that isn't listed in the addon's $extensions (or otherwise discovered) won't show in the block type dropdown. If your type is missing from the admin, check the service provider before checking the class.
Slug collisions across modules. Block type slugs share a namespace with field types and other extensions. Prefix them (acme_hero_banner) to avoid being shadowed by a module installed later.
Fallback behavior for missing slugs. block('does-not-exist') returns null/false — it does not throw. In templates, guard it:
{% set promo = block('footer-promo') %}
{% if promo %}<div class="promo">{{ promo|raw }}</div>{% endif %}
Otherwise you render empty wrapper markup, and QA reports it as a styling bug.
Extension points are narrow but real. The repository criteria, the BlockExtension base class, block type form builders, and the admin table columns are all overridable. The intended customization path is a custom block type, not a subclass of BlockModel — the model is heavily Streams-oriented and subclassing it tends to break eager loading.
Debugging checklist when a block renders nothing: (1) is the addon installed and enabled — php artisan addon:list; (2) did streams:compile run after install; (3) is the block published and in the right area; (4) is the block type extension registered; (5) is the cache stale. In practice, 80% of "broken blocks" are steps 1 and 5.
How can I help you explore Laravel packages today?