Add Dynamic Content to Pages

Use an action when a Blade element or JSON:API response needs data from a database or service at render time. An action is an invokable PHP class selected by the element's trusted schema.

Server-rendered action data keeps the first response complete, but it also participates in the page's caching behavior. Keep actions deterministic for public cacheable pages and select only the data the template needs.

Source compatibility: This guide was verified against PagibleAI commit 3c149a3 on 2026-08-01. If you use a tagged release, compare the action and query APIs with your installed package.

Current implementation references: trusted action dispatch, built-in Blog action, latest-version query scope and complete-page cache middleware.

Create an action class

Application actions can live in app/Cms/. Composer's normal App\ autoloading makes app/Cms/LatestArticles.php available as App\Cms\LatestArticles:

<?php

namespace App\Cms;

use Aimeos\Cms\Models\Page;
use Aimeos\Cms\Permission;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;

class LatestArticles
{
    public function __invoke(
        Request $request,
        Page $page,
        object $item
    ): LengthAwarePaginator {
        $limit = max(1, min((int) ($item->data->limit ?? 10), 50));
        $editor = Permission::can('page:view', $request->user());

        $with = $editor
            ? ['latest' => fn($query) => $query->select(
                'id',
                'tenant_id',
                'versionable_id',
                'data',
                'aux'
            )]
            : [];

        $query = Page::query()
            ->with($with)
            ->where('parent_id', $page->id)
            ->where('type', 'blog')
            ->orderByDesc('created_at');

        // This is a public teaser list. Access remains enforced on detail pages.
        if ($editor) {
            $query->whereLatest(['status' => 1]);
        } else {
            $query->where('status', 1);
        }

        return $query->paginate(
            $limit,
            [
                'id',
                'tenant_id',
                'latest_id',
                'lang',
                'path',
                'domain',
                'name',
                'title',
                'to',
                'created_at'
            ],
            'articles'
        );
    }
}

Laravel's container calls __invoke() and injects the current Request. PagibleAI supplies:

  • $page: the page currently being rendered
  • $item: the content element that selected the action

The element has id, type, group and a data object containing the editor's field values.

Register the handler in schema.json

Add a hidden action field to the element in your custom theme's schema.json:

{
    "content": {
        "latest-articles": {
            "group": "content",
            "icon": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M4 4h16v16H4z\"/></svg>",
            "fields": {
                "title": {
                    "type": "string",
                    "label": "Title",
                    "default": "Latest articles"
                },
                "limit": {
                    "type": "number",
                    "label": "Number of articles",
                    "min": 1,
                    "max": 50,
                    "default": 10
                },
                "action": {
                    "type": "hidden",
                    "value": "\\App\\Cms\\LatestArticles"
                }
            }
        }
    }
}

The handler is resolved from the registered schema by element type. PagibleAI ignores an action callable supplied only by stored page data, preventing an editor-controlled value from becoming executable server code.

Read element settings

Read the editor's values from $item->data. Use defaults because older page versions may predate a newly added schema field:

$title = $item->data->title ?? 'Latest articles';
$limit = max(1, min((int) ($item->data->limit ?? 10), 50));

Validate any request parameter separately. Schema validation protects stored element settings; it does not make query-string or header values trustworthy.

Return published data and editor previews

Published page columns contain the public version. Editors with page:view should filter and render through the latest version. Use whereLatest() for its cross-database JSON handling and include latest_id whenever you eager-load latest from a selective query.

This example returns visible child pages of type blog. Change that type when your article page type uses another schema key:

use Aimeos\Cms\Permission;
use Aimeos\Cms\Models\Page;

$editor = Permission::can('page:view', $request->user());

$with = $editor
    ? ['latest' => fn($query) => $query->select(
        'id',
        'tenant_id',
        'versionable_id',
        'data',
        'aux'
    )]
    : [];

$query = Page::query()
    ->with($with)
    ->where('parent_id', $page->id)
    ->where('type', 'blog')
    ->orderByDesc('created_at');

if ($editor) {
    $query->whereLatest(['status' => 1]);
} else {
    $query->where('status', 1);
}

return $query->paginate(
    $item->data->limit ?? 10,
    [
        'id',
        'tenant_id',
        'latest_id',
        'lang',
        'path',
        'domain',
        'name',
        'title',
        'to',
        'created_at'
    ],
    'articles'
);

In the Blade template, read version-aware values with cms($article, 'title') and related helpers. This returns the draft value for an authorized editor and the published value for visitors.

Protect detail data

A public listing can intentionally expose teaser fields such as title, route and summary even when the detail page has an access rule. Do not return the protected page body, private files or other detail-only data in that teaser.

The title-only example deliberately omits content. JSON:API serializes the action return value, so every selected model attribute can cross the Blade boundary. If a teaser value lives inside content, map the result to an explicit public array or data-transfer object and discard the source body before returning it.

If an action must expose protected detail data instead, apply the frontend rule scope with ->access($request->user()) and use cmsasset($page, $file) for page-aware private file URLs.

Paginate independent actions

Give each paginator a unique query parameter name. Two actions using p would both react to the same request value:

$articles = $query->paginate(10, $columns, 'articles');
$events = $eventQuery->paginate(10, $eventColumns, 'events');

Keep the existing query values when rendering links:

{{ $action->appends(request()->query())->links() }}

Render the action result

cmsdata() passes the return value to the element view as $action. A simple views/latest-articles.blade.php can render the paginator directly:

<section>
    <h2>{{ $data->title ?? 'Latest articles' }}</h2>

    @foreach($action ?? [] as $article)
        <article>
            <h3>
                <a href="{{ cmsroute($article) }}">
                    {{ cms($article, 'title') }}
                </a>
            </h3>
        </article>
    @endforeach

    {{ $action?->appends(request()->query())?->links() }}
</section>

For JSON:API output, return a scalar, array, paginator, collection or another value Laravel can serialize. Keep the shape stable because API clients consume it as part of the content element. Treat every returned attribute and loaded relation as public API data.

Load related files efficiently

Avoid querying files inside a Blade loop. Collect the public teaser file IDs, load them once, and attach only the resulting keyed collection or mapped file data.

If those IDs currently live inside page content, read the source only while building the result, then map each row to an explicit teaser shape and discard content before returning it. Editors can read IDs from latest.aux.content; visitors use the published source. Never serialize the full source body merely to expose one teaser image.

Always select tenant_id with CMS models and latest_id when a selected relation depends on it.

Understand page caching

PagibleAI's complete-page cache is used for anonymous GET requests only when the request has no query string, session cookie or Authorization header and the public page has a positive cache duration. Query strings bypass the application page cache, although an upstream proxy may still cache a deterministic response by its full URL.

For public cacheable pages, an action should return the same output for every visitor until the cache expires or the page is invalidated. Do not use it for random, per-session or user-specific output.

For personalized content, use an authenticated uncached page or fetch the section after an explicit browser interaction. Set the page cache duration to 0 when the whole response must remain uncached.

Search indexing

PagibleAI's normal search index is built from stored searchable content fields. Runtime action output is not a durable search source. Persist text that must be searchable in page or element fields, or maintain a separate index for the external data.

Test the teaser boundary

Keep the projection covered by a feature test. This example stores a marker in the article body, invokes the action as a visitor and proves that the paginator contains the public title without serializing the body. If your teaser adds a summary or image, assert the exact public shape as well:

<?php

namespace Tests\Feature;

use App\Cms\LatestArticles;
use Aimeos\Cms\Models\Page;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;

class LatestArticlesTest extends TestCase
{
    use RefreshDatabase;

    public function testItReturnsOnlyPublicTeaserData(): void
    {
        $parent = Page::forceCreate([
            'lang' => 'en',
            'name' => 'Articles',
            'title' => 'Articles',
            'path' => 'articles',
            'type' => 'page',
            'status' => 1,
            'editor' => 'test',
        ]);

        $article = Page::forceCreate([
            'lang' => 'en',
            'name' => 'First article',
            'title' => 'First article',
            'path' => 'first-article',
            'type' => 'blog',
            'status' => 1,
            'editor' => 'test',
            'content' => [
                [
                    'id' => 'body',
                    'type' => 'text',
                    'group' => 'main',
                    'data' => ['text' => 'PRIVATE_DETAIL_MARKER'],
                ],
            ],
        ]);
        $article->appendToNode($parent)->save();

        $request = Request::create('/articles', 'GET');
        $item = (object) ['data' => (object) ['limit' => 10]];

        $result = (new LatestArticles())($request, $parent, $item);
        $json = json_encode($result, JSON_THROW_ON_ERROR);

        $this->assertSame(1, $result->total());
        $this->assertStringContainsString('First article', $json);
        $this->assertStringNotContainsString('PRIVATE_DETAIL_MARKER', $json);
        $this->assertArrayNotHasKey(
            'content',
            $result->items()[0]->getAttributes()
        );
    }
}

Troubleshoot action results

  • The handler never runs: define action as a hidden field in the registered schema and confirm the stored element type matches that schema key. A callable placed only in page data is deliberately ignored.
  • A route or template value is missing: include every property the view helpers read in the query's selected columns. Keep to when cmsroute() must honor redirect pages.
  • Editors see published values instead of drafts: use whereLatest(), eager-load latest, and select its data and aux columns.
  • Anonymous output looks stale: complete-page caching can preserve action output for the page's cache duration. Reduce or disable that duration for frequently changing results.
  • Two paginators move together: give each action a distinct page parameter and preserve the request query when rendering links.

Action checklist

  • Register the handler as a hidden field in a trusted theme schema.json.
  • Bound stored limits and validate all request input.
  • Select only required columns and eager-load relations.
  • Use whereLatest() plus latest_id for editor-aware selective queries.
  • Keep public teaser data separate from access-controlled detail data and test the serialized result.
  • Give each paginator a unique query parameter.
  • Keep public cached output deterministic.
  • Keep search-critical text in stored searchable fields.