Skip to content
Phem
GitHub

Compiling .phpm files

A .phpm project is a Symfony application with PhemBundle. It needs two build steps before it can run:

  1. phemc build (Rust) compiles the .phpm files to PHP and Twig.
  2. bin/console phem:warmup (PHP) compiles the Twig templates into the Twig cache.

phemc build runs step 2 for you. At runtime, nothing parses .phpm or .twig sources.

Project layout#

my-app/
├── composer.json          requires phem/phem (with symfony/stimulus-bundle) and symfony/asset-mapper (and symfony/runtime, symfony/yaml, symfony/dotenv)
├── .symfony.local.yaml    the phemc worker of `symfony serve`: `vendor/bin/phemc watch src`
├── .env                   APP_ENV, APP_SECRET (it signs the component state in the browser)
├── Caddyfile              FrankenPHP: public/index.php in worker mode, and the static files
├── bin/console
├── config/
│   ├── bundles.php        FrameworkBundle, TwigBundle, StimulusBundle, PhemBundle (and DoctrineBundle for transactions and #[Path] entities)
│   ├── packages/phem.yaml   optional: build_dir, secret
│   ├── packages/asset_mapper.yaml   the assets/ directory
│   ├── routes.yaml        { resource: routing.controllers }: the #[Route] controllers and the #[Page] pages
│   └── services.yaml      your services (for example repositories)
├── src/                   the .phpm sources (the "source root"), and your PHP classes
│   ├── Kernel.php
│   ├── Model/             optional: Doctrine entities
│   ├── Repository/        normal PHP services, injected into components
│   ├── DataFixtures/      optional: Doctrine fixtures, the first rows of a new database
│   ├── Component/        components that are not pages
│   │   └── Document.phpm   the HTML document: each page renders in it, with a <Root>
│   └── Page/             the pages: classes and functions with a #[Page] attribute
│       ├── index.phpm      #[Page('/')]
│       └── Example/
│           └── Counter.phpm #[Page('/examples/Counter')]
├── assets/                the JavaScript and CSS of the pages (AssetMapper)
│   ├── app.js             the `app` entry of the importmap: bootstrap.js and styles/app.css
│   ├── bootstrap.js       starts Stimulus (StimulusBundle)
│   └── controllers.json   enables the `phem` controller of phem/phem
├── importmap.php          app, @hotwired/stimulus, @hotwired/turbo, @symfony/stimulus-bundle
├── public/                static files, index.php (Symfony Runtime: FrankenPHP worker mode, PHP-FPM)
└── var/
    ├── phem/              the phemc output (do not commit)
    └── cache/             the Symfony cache (do not commit)

The directories are only a convention: a page is a class with the #[Page] attribute and a render() method, or a function with the #[Page] attribute, in any file.

The document of the pages#

The application owns the HTML document: a component in the .phpm sources, as the others. It returns the <html> element, and it prints the content of the page in a <Root>. Phem adds the <!DOCTYPE html>.

<?phpm

namespace App\Component;

use Phem\Attribute\Component;
use Phem\{Html, Node, Root};

#[Component]
function Document(?Html $title = null, ?Html $children = null): Node
{
    return (
        <html lang="en">
            <head>
                <title>{{ title ?? 'My app' }}</title>
                {{ importmap('app') }}
            </head>
            <body><Root>{{ children }}</Root></body>
        </html>
    );
}

Each page renders its content in it. A page gives the title with the title slot:

use Phem\Node;

use function App\Component\Document;

#[Page('/scientists/{id:scientist}')]
final class ScientistPage
{
    #[Path] public Scientist $scientist;

    public function render(): Node
    {
        return (
            <Document>
                <Document:title>{{ scientist.name }}</Document:title>
                <h1>{{ scientist.name }}</h1>
            </Document>
        );
    }
}

The document can have more slots, for example a ?Html $head slot for the <meta> tags of a page. For another layout (an admin area, for example), write another document component, and use it in the pages of that area.

<Root> (Phem\Root) is a built-in component: import it with use Phem\Root;. It is the root element of the page:

In the document Gives
<Root>{{ children }}</Root> <div id="phem-root" data-controller="phem" …> with the page
<Root tag="main" className="app">{{ children }}</Root> the same, with more attributes and another tag

The root element has the attributes of the phem Stimulus controller: they give the component state to the controller. tag is the tag name (div by default). The other props are attributes, as on an element (className, a class list, style, data-*). The id is phem-root (id={{ null }} removes it). Your data-controller and data-action values come after the values of Phem. You cannot change the state attributes, and the root element cannot have an event handler.

A page renders one <Root>. A page with no <Root> or with two is an error, and a <Root> in a Twig template outside a page is an error too.

The events render only the components that can change (see Keep render() pure), and they update only the content of the <Root> in the browser. The document around it (the title, a footer) does not change. A component after the root element renders, but the phem controller does not get its DOM events. A <title> in the markup of a page is not the title of the page: phemc reports it (W005). A <title> in <head> or in an <svg> is correct.

The browser sends the events of a page to the URL of the page, and only a page route runs them.

The routes of the pages#

The config/routes.yaml file of a Symfony application (7.4 and later) imports the routing.controllers resource: the routes of the #[Route] controllers. PhemBundle adds the routes of the #[Page] pages to this resource, so the pages need no routes file:

controllers:
    resource: routing.controllers

An application without this resource (made before Symfony 7.4) imports the pages with the phem type:

# config/routes/phem.yaml
phem:
    resource: .
    type: phem

Use only one of the two. With the two, each page has its routes two times, and the last import replaces the routes of the first one.

The client assets#

The client of Phem is the phem Stimulus controller, and the phem-c{display:contents} CSS (for the <phem-c> around a component that does not render one element). The bundle gives them as a Symfony UX package (assets/package.json). With AssetMapper, they are in the phemjs namespace. The controller imports @hotwired/stimulus and @hotwired/turbo from the importmap of the application, so the page has one Turbo and one Stimulus. phem/phem requires StimulusBundle. Install AssetMapper and StimulusBundle before phem/phem (the --webapp skeleton has them):

composer require symfony/asset-mapper symfony/stimulus-bundle
composer require phem/phem

Symfony Flex enables the controller in assets/controllers.json, and adds @hotwired/stimulus and @hotwired/turbo to importmap.php:

{
    "controllers": {
        "@phem/phem": {
            "phem": { "enabled": true, "fetch": "eager", "autoimport": { "@phem/phem/dist/phem.css": true } }
        }
    },
    "entrypoints": []
}

The package also has optional controllers, which are not enabled by default (see the .phpm syntax guide):

  • phem-optimistic (phemjs/phem_optimistic_controller.js) changes the page before the server answers an event.
  • phem-toggle (phemjs/phem_toggle_controller.js) shows and hides a dropdown, a menu or a section, and keeps it open after the morph of an event.
  • phem-dialog (phemjs/phem_dialog_controller.js) opens a native <dialog> as a modal, and keeps it open after the morph of an event.
  • phem-tabs (phemjs/phem_tabs_controller.js) shows the panel of the selected tab, with the ARIA roles and the keyboard of a tab list, and keeps the tab after the morph of an event.
  • phem-toast (phemjs/phem_toast_controller.js) shows the phem:toast, phem:error and phem:rate-limited browser events as toasts.
  • phem-clipboard (phemjs/phem_clipboard_controller.js) copies a text to the clipboard, and keeps its "copied" feedback after the morph of an event.
  • phem-autofocus (phemjs/phem_autofocus_controller.js) gives the focus to an element when it connects, also when the morph of an event adds it.
  • phem-confirm (phemjs/phem_confirm_controller.js) asks the user to confirm before an event of a handler goes to the server.

Enable a controller in assets/controllers.json with its key in the @phem/phem entry, for example "toggle": { "enabled": true, "fetch": "lazy" }.

1. Get the tools#

In an application, composer require phem/phem gives vendor/bin/phemc. On the first run, it downloads the phemc binary of the installed version from the GitHub release. You also need PHP 8.5 and, for the worker mode, FrankenPHP.

To work on this repository, use Docker (recommended). The dev image has FrankenPHP (PHP 8.5), Composer, and the Rust toolchain:

make shell      # a shell in the dev image; `phemc` (built from compiler/) is on the PATH

Without Docker, you need PHP 8.5, FrankenPHP, and Rust:

cargo build --release --manifest-path compiler/Cargo.toml -p phemc-cli
export PATH="$PWD/compiler/target/release:$PATH"
composer install

2. Build#

Run the commands in the project directory (the directory that contains bin/console).

phemc build src

Output:

phemc: compiled 15 file(s), 24 template(s), 10 page(s); 43 file(s) changed, 0 warning(s) in 51 ms
phem: compiled 24 template(s) into the Twig cache in 250 ms

The build is incremental: it writes only the files that changed, so the Twig cache and OPcache stay valid. If there is an error, it writes nothing and exits with code 1.

The build also compiles the .phpm files of the Composer packages that give extra.phem.source in their composer.json (see Component packages). The summary then gives their number: compiled 42 file(s) (2 package(s)), ....

Option Default Meaning
--out <dir> var/phem The output directory (the same as the phem.build_dir option)
--php <bin> php The PHP binary for the Twig warmup
--no-warmup off Do not run bin/console phem:warmup

The warmup uses the environment of APP_ENV. In debug mode (dev), Symfony builds the container and the routes again when the manifest or a compiled page changes: a new component, page, or route needs no cache:clear.

What the build writes#

var/phem/
├── php/Page/index.php          the PHP code; each markup literal becomes new \Phem\Element('Page/index.0.twig', [...])
├── php/vendor/acme/ui/...      the PHP code of a Composer package (its templates are in templates/vendor/acme/ui/)
├── templates/Page/index.0.twig one Twig template for each markup literal (Twig name: @phpm/Page/index.0.twig)
├── manifest.php / manifest.json components (kind, source, props, slots, replaces), pages, template → .phpm line map
└── autoload.php                 requires all compiled PHP files
var/cache/<env>/twig/            the Twig cache (filled by the warmup)

var/phem/ is not a cache: it holds the compiled code of the application, and only phemc build writes it. It is the same for all environments. cache:clear and rm -rf var/cache do not delete it.

The compiled PHP keeps the line numbers of the .phpm file. The manifest maps each template back to its .phpm file (from the project root) and line, so the Symfony error page and the console show src/<file>.phpm (or vendor/<package>/... for a package) and its line for an error in markup.

3. Check only (CI)#

phemc check src

It reports all diagnostics, writes nothing, and exits with code 1 if there is an error. Each diagnostic shows the .phpm line and column, a label, and a hint:

E011

  × `<Profile>` has no prop `persn`
    ╭─[src/Page/App.phpm:16:22]
 15 │         <div>
 16 │             <Profile persn={{ user }} />
    ·                      ─────
 17 │         </div>
    ╰────
  help: its props are: person

Diagnostics#

phemc does not write output when it finds an error. The codes:

Code Meaning
E001–E006 Syntax: unclosed or mismatched tags, a void element that is not self-closed, more than one root, a <?php open tag instead of <?phpm
E010–E015 Unknown component (also a function without #[Component], or the component of a replaces), unknown or missing prop, children or slot not accepted, duplicate component (also a component with the name of a built-in component, Phem\Root, or an enum that two files declare), a required context with no provider above the component
E016, E017 A replacement (#[Component(replaces: ...)]) that does not accept a prop or a slot of the original, that requires one that the original does not require, or that declares a Bound prop of the original with another type; a replacement in a package, a second replacement of a component, a component that replaces itself, the replacement of a replacement, of a page or of a built-in component, a page that replaces a component, or a replaces value that is not Name::class or a string (also self::class)
E018 A literal value of a backed enum prop that is not the value of a case (variant="big", level="03", an empty value, or a bare attribute), when the enum is in a .phpm file
E020 A call to a React Hook (Phem\useState, ...): Phem keeps state in #[Component] classes
E030–E033 Undefined variable in markup, markup in an unsupported expression, invalid attribute, invalid phem:bind (not a #[State] property, not a form field, or a type that does not take the value of the field)
E040–E045 Class component without render(), readonly #[State] property, #[Prop] property that is not public, static, or #[State], a second #[Prop(rest: true)] property or one whose type does not accept an array, a #[Context] or #[Provide] type that is not one class, interface or enum (or a #[Context] property that is static, readonly, a prop or state), #[Component] on an abstract class
E050, E052, E053 export default (not PHP; use #[Page]), the Symfony #[Route] on a function (use #[Page]) or on a class that is not a component, #[Broadcast] on a class or a function that is not a page or a component, or on a method of a page
E054, E055 #[Async] on a function; an #[Async] method that does not declare Promise as its return type
E056 a page (a class or a function) used as a tag
E057 #[BroadcastKey] on a property that is not a prop, on a page, or in a component with no #[Broadcast] on it or on a handler
E058 #[Validated] on a handler of a component with no #[State] property of the type Phem\Validation (not static; also a method of a trait or a parent class), or on a function; a component with more than one Validation property
W001–W003 A handler is called instead of passed; a list item without key; an event that the browser does not send (onMouseEnter)
W004 #[Page], #[Route], #[Component], #[State], #[Prop], #[Path], #[Context], #[Provide], #[Broadcast], #[BroadcastKey], #[Async] or #[Validated] without its use import (Phem\Attribute\..., Symfony\Component\Routing\Attribute\Route)
W005 a <title> outside <head> and <svg>: the document component prints the title, and a page gives it with <Document:title>
W006 phem:debounce or phem:throttle on an element with no handler
W007 phem:live on an element with no phem:bind
W008 a render() (or a function component) that is not pure: it reads a service of the constructor, a static property, a superglobal, the clock, or a random value (see Keep render() pure)

For editors and other tools, --format json prints the diagnostics to stdout as JSON. The exit code does not change:

phemc check src --format json
{"files": [{"file": "src/Component/ScientistCard.phpm", "diagnostics": [{"severity": "error",
  "code": "E010", "message": "unknown component `<Avatr>` (resolved to `App\\Component\\Avatr`)",
  "label": null, "help": "declare `#[Component] function Avatr()` in a .phpm file, or check the `use` imports",
  "start": {"line": 30, "character": 13}, "end": {"line": 30, "character": 18}}]}]}

file is relative to the project root (src/Component/ScientistCard.phpm). line starts at 0, and character counts UTF-16 code units, as the Language Server Protocol does. The warnings of a Composer package are not in the list.

The language server#

phemc lsp runs the language server on stdin and stdout. It shows the same diagnostics as phemc check as you type, and it gives the completion, the definition, the hover, and the outline of .phpm files. The editors bundle the same server, compiled to WebAssembly (make lsp), so they do not need a phemc binary. See editors/vscode/README.md and editors/phpstorm/README.md. The Claude Code plugin runs vendor/bin/phemc lsp of the application: see Getting started and editors/claude-code/README.md.

The server also checks the .phpm files of the Composer packages with the app, and it gives the completion, the definition and the hover of their components in the files of the app. It shows the errors of a package file in the file (also at its real path, for a package of a Composer path repository). The app that installs a package checks its files: a linked package in the same workspace does not check itself. The server reads the package roots and the package files one time. It reads them again after a Composer change, after a change of a package file on disk, and when a source dir or a package root is new or goes away. When it cannot read a package root (a wrong extra.phem.source), it shows the error one time (and again after a Composer change), and it checks the files of the app only.

4. Run#

With FrankenPHP in worker mode, in the project directory:

frankenphp run --config Caddyfile
  • public/index.php uses Symfony Runtime. In a FrankenPHP worker, Symfony Runtime boots the kernel once and gives it many requests. The kernel resets the services (kernel.reset) between requests.
  • Caddy serves the static files of public/ at /. A directory serves its index.html. In development, Symfony serves the AssetMapper assets at /assets/.
  • FRANKENPHP_WORKER_CONFIG adds options to the worker. For example, watch ./src/**/*.php restarts the workers when a PHP class changes.
  • APP_ENV=dev (the default in .env) shows the Symfony error pages, with the .phpm location.
  • The application assumes that the database exists. Make it before the first run, for example bin/console doctrine:schema:create and bin/console doctrine:fixtures:load. In Docker, run these commands in the entrypoint, only when the database does not exist yet.

With PHP-FPM, use public/index.php as the front controller, as for any Symfony application.

The Symfony profiler#

In debug mode, Phem adds a panel to the Symfony profiler. Install the profiler in development:

composer require --dev symfony/web-profiler-bundle symfony/stopwatch

The Phem panel shows each page request and each event:

  • the page, the handler and its source, and the outcome (rendered, redirect, reload, or a rate limit);
  • the component tree, with the state of each component, its render time without its children, and the contexts that it provides and reads (with the provider);
  • the passes of an event: the components that rendered before the handler, the components that rendered after it, the components that rendered only because a context above them changed (propagated), and the components that changed (the browser morphs them);
  • the size of the signed state, and whether the state came from the state cache;
  • the bound values, the browser events of the handler, and the warnings of the dev-mode check;
  • the Doctrine transaction of the handler: commit or rollback, and its time;
  • the Turbo Streams that the request pushed to the Mercure hub, with their topics, and the stale components that an event rendered again (see Real-time updates).

The Performance panel shows the render passes, the handler and the transaction (phem category). With the profiler on, the browser console shows each event with a link to its Phem panel (console.debug). Without debug mode, Phem does not measure the renders.

Other commands:

php bin/console phem:warmup                          # compile the templates into the Twig cache again
php bin/console phem:render /examples/Counter        # print the HTML of a page (no server)
php bin/console debug:router                          # the routes of the pages and their events (phem_event.*)

5. Watch mode (development)#

With the Symfony CLI, symfony serve runs phemc watch as a worker of the web server. The Flex recipe of phem/phem adds it to .symfony.local.yaml:

workers:
    phemc:
        cmd: ['vendor/bin/phemc', 'watch', 'src']

When a .phpm file changes, phemc rebuilds. With PHP-FPM, the next request uses the new build: no restart is necessary. Run symfony server:log to see the diagnostics. phemc watch also rebuilds after composer require or composer update (a change of vendor/composer/installed.json), and when a .phpm file of a Composer package changes.

With FrankenPHP in worker mode, give the command of the server to --exec:

phemc watch src --exec "frankenphp run --config Caddyfile"

When a .phpm file changes, phemc rebuilds. If the build passes, it restarts FrankenPHP, so the workers load the new components. If the build fails, it prints the diagnostics and the previous server keeps running. Set FRANKENPHP_WORKER_CONFIG="watch ./src/**/*.php" to restart the workers also when a PHP class changes.

6. Production#

export APP_ENV=prod APP_DEBUG=0
composer install --no-dev --optimize-autoloader
phemc build src --no-warmup
rm -rf var/cache/prod && php bin/console cache:warmup
php bin/console asset-map:compile

In production, Symfony does not check the cache for changes. Build the cache again after each phemc build: cache:warmup builds the container (with the component services), the routes, and the Twig cache of the phemc templates. asset-map:compile writes the assets to public/assets/. Set a random APP_SECRET (for example in .env.local).

Then enable the preload. config/preload.php loads the preload file of the Symfony container. PhemBundle adds the compiled components to this file (with var/cache/prod/phem-preload.php), so config/preload.php needs no change:

opcache.validate_timestamps=0
opcache.preload=/app/config/preload.php
opcache.preload_user=root
Edit this page on GitHubLast updated
Phem
Esc
↑↓ to navigate↵ to openEsc to close