Skip to content

marko/layout

Everything is a component. Layouts, page sections, and widgets are composed via #[Component] attributes and assembled automatically per route. Cross-module injection uses the same Plugin/Preference system as the rest of Marko.

Terminal window
composer require marko/layout

A layout component is the page root. It declares the top-level slots that other components fill:

DefaultLayout.php
use Marko\Layout\Attributes\Component;
#[Component(
template: 'layouts/default.html',
slots: ['header', 'content', 'footer'],
)]
class DefaultLayout {}
layouts/default.html
<!doctype html>
<html>
<body>
{slot header}{/slot}
{slot content}{/slot}
{slot footer}{/slot}
</body>
</html>
ProductController.php
use Marko\Layout\Attributes\Layout;
use Marko\Routing\Attributes\Get;
#[Layout(component: DefaultLayout::class)]
class ProductController
{
#[Get('/products/{id}')]
public function show(int $id): void
{
// Side effects only — data comes from component data() methods
}
}

Components declare which slot they render into and which routes they appear on:

ProductContent.php
use Marko\Layout\Attributes\Component;
#[Component(
template: 'components/product-content.html',
handle: 'products_*',
slot: 'content', // matches all products_* routes
)]
class ProductContent
{
public function data(int $id): array
{
return ['productId' => $id];
}
}

Route parameters are injected into data() by name automatically.

Components can define their own sub-slots for deeper composition:

#[Component(
template: 'components/tabs.html',
handle: 'products_product_show',
slot: 'content',
slots: ['tab.details', 'tab.reviews'],
)]
class ProductTabs {}

Child components target these sub-slots using dot-notation:

#[Component(
template: 'components/reviews.html',
handle: 'default',
slot: 'tab.reviews',
)]
class ReviewsTab {}

Any module can modify the component collection via an #[After] plugin on ComponentCollectorInterface::collect():

use Marko\Core\Attributes\After;
use Marko\Core\Attributes\Plugin;
use Marko\Layout\ComponentCollection;
use Marko\Layout\ComponentCollectorInterface;
#[Plugin(target: ComponentCollectorInterface::class)]
class CustomizeLayoutPlugin
{
#[After]
public function collect(ComponentCollection $result): ComponentCollection
{
$result->remove(OtherComponent::class);
$result->move(MyComponent::class, 'sidebar', sortOrder: 5);
return $result;
}
}

Handles control which pages a component appears on:

Handle valueMatches
'default'Every page
'products_*'All routes whose handle starts with products_
'products_product_show'Only ProductController::show (exact match)
[ProductController::class, 'show']Resolved to the exact handle

marko/layout registers LayoutMiddleware as global middleware, so there is nothing to wire up. When a controller has a #[Layout] attribute, it delegates rendering to LayoutProcessor automatically.

LayoutMiddleware is marked #[RunsInnermost]: unlike other global middleware, it runs after every route middleware, directly around the controller action. That has two consequences:

  • Denied requests never render a layout. Route middleware such as authentication, #[Can] or a rate limiter answers before LayoutMiddleware runs. And LayoutMiddleware only replaces a successful controller result (a 2xx response without a Location header) with the rendered layout; any other result --- a 404 thrown by the controller, a redirect it returns --- passes through untouched.
  • Headers and cookies survive. Route middleware wraps the rendered layout response, so a Set-Cookie, a Cache-Control or any custom header it adds on the way out reaches the client. Headers and cookies the controller put on its own response are copied onto the layout response too. Where both set the same header or cookie, the layout’s wins, and Content-Type and Content-Length always describe the rendered layout.
app/web/src/Http/Middleware/NoStoreMiddleware.php
class NoStoreMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next): Response
{
// $next() returns the rendered layout for a #[Layout] route.
return $next($request)->withHeader('Cache-Control', 'no-store');
}
}

Global middleware (sessions, CSRF, security headers, page cache) still wraps everything, including the layout.

Override any component via Preferences to swap implementations without modifying vendor code.

// Attributes
#[Component(
template: string,
handle: string|array,
slot: ?string,
slots: array,
sortOrder: int,
before: ?string,
after: ?string,
)]
#[Layout(component: string)]
// ComponentCollection
$componentCollection->add(ComponentDefinition $definition): void
$componentCollection->remove(string $className): void
$componentCollection->get(string $className): ComponentDefinition
$componentCollection->move(string $className, string $slot, ?int $sortOrder = null): void
$componentCollection->forSlot(string $slot): array
$componentCollection->count(): int
// LayoutProcessor
$processor->process(string $controllerClass, string $action, string $routePath, array $routeParameters, Request $request): Response