Getting started
A complete, copy-paste tutorial for a small blog: home page, an articles list, an about page, and a contact form. Everything here uses the SERIXA CLI and public APIs only — no framework source changes.
Time budget: ~30 minutes if you copy-paste each step.
php -SScaffold + AppBar + nav + footer)Card-based article listForm with labeled fieldspublic/index.php (SERIXA does not ship a router)Two ways to get the CLI:
# Inside a clone of the framework
php bin/serixa
# Or, once installed as a dependency
vendor/bin/serixaImportant: generate your app outside the SERIXA framework clone (a sibling folder), not inside it. If your app's
composer.jsonuses apathrepository pointing at a parent directory, Composer can recurse into nestedvendorfolders and hang. See Path-repository recursion.
serixa new blogThis scaffolds a blog/ folder next to wherever you ran the command, with a blog preset:
blog/
public/
index.php
app.css
assets/runtime/serixa.js
pages/
HomePage.php
PostsPage.php
components/
layouts/
AppLayout.php
config/app.php
composer.jsonPostsPage.php is a leftover preset stub — the blog preset generates it, but nothing routes to it yet. We'll build our own ArticlesPage instead and delete the stub at the end.
cd blog
composer install
php -S localhost:8000 -t publicOpen http://localhost:8000. You should see "Welcome to your SERIXA blog application." That's HomePage.php rendering through AppLayout.
Note the -t public flag: SERIXA apps always serve from public/, because that's where index.php and assets/runtime/serixa.js live.
pages/HomePage.php:
<?php
declare(strict_types=1);
namespace App\Pages;
use App\Layouts\AppLayout;
use Serixa\Component\Column;
use Serixa\Component\Text;
use Serixa\Document\Document;
final class HomePage
{
public static function document(): Document
{
$body = Column::make([
AppLayout::titleBar('Blog · blog'),
Text::make('Welcome to your SERIXA blog application.')
->tone('muted'),
])->gap(3)->alignStart();
return Document::make()
->title('Blog · blog')
->description('SERIXA blog starter')
->language('en')
->body($body);
}
}layouts/AppLayout.php:
<?php
declare(strict_types=1);
namespace App\Layouts;
use Serixa\Component\Container;
use Serixa\Component\Text;
use Serixa\Contracts\WidgetInterface;
use Serixa\Assets\StyleSheet;
use Serixa\Document\Document;
use Serixa\Runtime\ClientRuntime;
final class AppLayout
{
public static function wrap(Document $document): Document
{
StyleSheet::register($document->assets());
ClientRuntime::register($document->assets(), '/assets/runtime/serixa.js');
return $document;
}
public static function titleBar(string $title): WidgetInterface
{
return Container::make(
Text::make($title)->as('h1')->size('2xl')->weight('bold'),
);
}
}public/index.php:
<?php
declare(strict_types=1);
use App\Layouts\AppLayout;
use App\Pages\HomePage;
use Serixa\Rendering\Renderer;
require dirname(__DIR__) . '/vendor/autoload.php';
$page = strtolower((string) ($_GET['page'] ?? 'home'));
$document = match ($page) {
default => HomePage::document(),
};
header('Content-Type: text/html; charset=utf-8');
echo (new Renderer())->render(AppLayout::wrap($document));Three things to notice, since they shape everything below:
document(): Document method. No base class required.AppLayout::wrap() registers assets (Tailwind CDN script + your CSS + the runtime JS) on the Document before rendering.match() in index.php. That's app code, not a framework feature — SERIXA has no router. You will extend this match() yourself as you add pages.Open pages/HomePage.php and replace the body:
<?php
declare(strict_types=1);
namespace App\Pages;
use App\Layouts\AppLayout;
use Serixa\Component\Button;
use Serixa\Component\Column;
use Serixa\Component\Row;
use Serixa\Component\Text;
use Serixa\Document\Document;
final class HomePage
{
public static function document(): Document
{
$body = Column::make([
Text::make('Ideas for building calmer software')
->as('h1')->size('2xl')->weight('bold'),
Text::make('A small blog about PHP, product craft, and shipping things that work.')
->size('lg')->tone('muted'),
Row::make([
Button::make('Read the articles')->primary()->large(),
Button::make('Get in touch')->secondary()->large(),
])->gap(3),
])->gap(4)->alignStart();
return Document::make()
->title('My SERIXA Blog')
->description('A small blog built with SERIXA.')
->language('en')
->body($body);
}
}Refresh http://localhost:8000 — you should see the new heading, subtext, and two buttons. The buttons don't link anywhere yet; that's fixed once the layout has navigation.
Right now AppLayout::titleBar() is just a Container — no navigation, no footer. Replace the whole file with a Scaffold-based shell:
<?php
declare(strict_types=1);
namespace App\Layouts;
use Serixa\Component\AppBar;
use Serixa\Component\Container;
use Serixa\Component\Footer;
use Serixa\Component\Link;
use Serixa\Component\Logo;
use Serixa\Component\Scaffold;
use Serixa\Component\Text;
use Serixa\Contracts\WidgetInterface;
use Serixa\Document\Document;
use Serixa\Runtime\ClientRuntime;
final class AppLayout
{
private const NAV = [
'home' => 'Home',
'articles' => 'Articles',
'about' => 'About',
'contact' => 'Contact',
];
/**
* Register CSS/JS on the Document. Call this once per request, right before render.
*/
public static function wrap(Document $document): Document
{
StyleSheet::register($document->assets());
ClientRuntime::register($document->assets(), '/assets/runtime/serixa.js');
return $document;
}
/**
* Wrap page content in the Scaffold shell (AppBar + nav + footer).
*/
public static function shell(string $active, WidgetInterface $content): WidgetInterface
{
$links = [];
foreach (self::NAV as $page => $label) {
$links[] = Link::make($label)
->href('/?page=' . $page)
->active($page === $active);
}
$bar = AppBar::make(Logo::make()->text('My Blog')->href('/?page=home'))
->actions(...$links);
$footer = Footer::make(
Text::make('© 2026 My Blog.')->size('sm')->tone('muted'),
);
return Scaffold::make()
->appBar($bar)
->body(Container::make($content)->maxWidth('lg')->padding('lg'))
->footer($footer);
}
}Key points:
AppBar::make() takes the leading content (here, a Logo) and ->actions(...) for right-aligned items (here, nav links).Scaffold::make()->appBar(...)->body(...)->footer(...) is purely structural — it does not add auth, state, or routing.Link::make($label)->active($page === $active) is how you mark the current nav item. SERIXA does not track "current page" for you; your app passes $active in.Now wire HomePage through the new shell. Update pages/HomePage.php:
<?php
declare(strict_types=1);
namespace App\Pages;
use App\Layouts\AppLayout;
use Serixa\Component\Button;
use Serixa\Component\Column;
use Serixa\Component\Row;
use Serixa\Component\Text;
use Serixa\Document\Document;
final class HomePage
{
public static function document(): Document
{
$content = Column::make([
Text::make('Ideas for building calmer software')
->as('h1')->size('2xl')->weight('bold'),
Text::make('A small blog about PHP, product craft, and shipping things that work.')
->size('lg')->tone('muted'),
Row::make([
Button::make('Read the articles')->primary()->large(),
Button::make('Get in touch')->secondary()->large(),
])->gap(3),
])->gap(4)->alignStart();
return Document::make()
->title('My SERIXA Blog')
->description('A small blog built with SERIXA.')
->language('en')
->body(AppLayout::shell('home', $content));
}
}Refresh the browser. You now have a top bar with your logo and nav links, page content in a centered container, and a footer.
Use the CLI's make:page command for each one:
serixa make:page Articles
serixa make:page About
serixa make:page ContactThis creates pages/ArticlesPage.php, pages/AboutPage.php, and pages/ContactPage.php, each with a generic placeholder body. We'll fill them in next.
Run
make:pageagain with the same name and it refuses to overwrite the file (Refusing to overwrite existing file). Rename the page, or delete the existing file first, if you need to regenerate it.
Replace pages/ArticlesPage.php:
<?php
declare(strict_types=1);
namespace App\Pages;
use App\Layouts\AppLayout;
use Serixa\Component\Badge;
use Serixa\Component\Card;
use Serixa\Component\Column;
use Serixa\Component\Link;
use Serixa\Component\PageHeader;
use Serixa\Component\Text;
use Serixa\Document\Document;
final class ArticlesPage
{
/** @var list<array{title: string, excerpt: string, category: string}> */
private const POSTS = [
['title' => 'Server-rendered interfaces that feel alive', 'excerpt' => 'How progressive enhancement keeps PHP apps fast without a client framework.', 'category' => 'Engineering'],
['title' => 'Design systems for small teams', 'excerpt' => 'A lightweight approach to shared language and reusable patterns.', 'category' => 'Design'],
['title' => 'Writing PHP you can read in a year', 'excerpt' => 'Small habits that keep a codebase readable as it grows.', 'category' => 'Engineering'],
];
public static function document(): Document
{
$cards = [];
foreach (self::POSTS as $post) {
$cards[] = Card::make(
Column::make([
Badge::make($post['category'])->color('primary'),
Text::make($post['title'])->as('h2')->size('xl')->weight('bold'),
Text::make($post['excerpt'])->tone('muted'),
Link::make('Read article')->href('/?page=articles'),
])->gap(2)->alignStart(),
)->padding('lg');
}
$content = Column::make([
PageHeader::make()->title('Articles')->subtitle('Everything we have published so far.'),
Column::make($cards)->gap(4)->alignStart(),
])->gap(5);
return Document::make()
->title('Articles · My SERIXA Blog')
->body(AppLayout::shell('articles', $content));
}
}This is a static list for the tutorial. In a real app you'd load posts from a database or flat files and map them the same way — SERIXA only cares that you hand it widgets, not where the data came from.
Visit http://localhost:8000/?page=articles once routing is wired in Step 9 — for now, note that AppLayout::shell('articles', ...) already highlights the "Articles" nav link.
Replace pages/AboutPage.php:
<?php
declare(strict_types=1);
namespace App\Pages;
use App\Layouts\AppLayout;
use Serixa\Component\Column;
use Serixa\Component\PageHeader;
use Serixa\Component\Text;
use Serixa\Document\Document;
final class AboutPage
{
public static function document(): Document
{
$content = Column::make([
PageHeader::make()->title('About')->subtitle('Why this blog exists.'),
Text::make('This is a small blog built to try out SERIXA — a PHP UI framework that renders HTML on the server.')
->tone('muted'),
])->gap(4);
return Document::make()
->title('About · My SERIXA Blog')
->body(AppLayout::shell('about', $content));
}
}Replace pages/ContactPage.php:
<?php
declare(strict_types=1);
namespace App\Pages;
use App\Layouts\AppLayout;
use Serixa\Component\Alert;
use Serixa\Component\Button;
use Serixa\Component\Column;
use Serixa\Component\FieldGroup;
use Serixa\Component\Form;
use Serixa\Component\Input;
use Serixa\Component\Label;
use Serixa\Component\PageHeader;
use Serixa\Component\Textarea;
use Serixa\Document\Document;
final class ContactPage
{
public static function document(): Document
{
$form = Form::make(
Column::make([
FieldGroup::make(
Label::make('Name')->for('contact-name'),
Input::make()->id('contact-name')->name('name')->autocomplete('name')->required(),
),
FieldGroup::make(
Label::make('Email')->for('contact-email'),
Input::make()->id('contact-email')->name('email')->type('email')->autocomplete('email')->required(),
),
FieldGroup::make(
Label::make('Message')->for('contact-message'),
Textarea::make()->id('contact-message')->name('message')->rows(6)->minLength(10)->required(),
),
Button::make('Send message')->type('submit')->primary(),
])->gap(4),
)->method('POST')->action('/?page=contact');
$content = Column::make([
PageHeader::make()->title('Contact')->subtitle('Send a note — we read every message.'),
Alert::make()->tone('info')->title('Heads up')->body('This form renders HTML only. Wire your own handler to actually receive submissions.'),
$form,
])->gap(5);
return Document::make()
->title('Contact · My SERIXA Blog')
->body(AppLayout::shell('contact', $content));
}
}Two things worth calling out:
Form is render-only. SERIXA emits a real <form method="POST" action="...">, but nothing in the framework parses $_POST or validates fields. That's entirely your app's job.FieldGroup + Label::for() + Input::id() is the pattern for accessible fields: the label's for matches the input's id.SERIXA ships no router. index.php is a plain match() over $_GET['page'] — extend the one that's already there. Replace public/index.php:
<?php
declare(strict_types=1);
use App\Layouts\AppLayout;
use App\Pages\AboutPage;
use App\Pages\ArticlesPage;
use App\Pages\ContactPage;
use App\Pages\HomePage;
use Serixa\Rendering\Renderer;
require dirname(__DIR__) . '/vendor/autoload.php';
$page = strtolower((string) ($_GET['page'] ?? 'home'));
$document = match ($page) {
'home' => HomePage::document(),
'articles' => ArticlesPage::document(),
'about' => AboutPage::document(),
'contact' => ContactPage::document(),
default => HomePage::document(),
};
header('Content-Type: text/html; charset=utf-8');
echo (new Renderer())->render(AppLayout::wrap($document));This is plain PHP — no framework hook, no config file, no annotations. If you outgrow match(), drop in any routing library you like (or write your own); SERIXA does not care how $document was chosen, only that you hand it a Document.
Now the nav links from Step 5 all resolve:
Click through the nav bar and confirm the active link highlights on each page.
pages/PostsPage.php was generated by the blog preset but never wired into routing. Delete it:
rm pages/PostsPage.php(On Windows PowerShell: Remove-Item pages\PostsPage.php.)
You now have a small, real SERIXA app: a shared layout, four pages, a card list, and a contact form — built entirely from public APIs and the CLI.
Keep these framework boundaries in mind as you grow the app:
Form renders HTML; processing $_POST is your app's code.components/ArticleCard.php with serixa make:component ArticleCard instead of building cards inline in ArticlesPage.