Skip to content

epic-64/elem

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

132 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Elem

Tests Coverage Lib Lines Test Lines PHP PHPStan License Packagist

Finally, you can be an HTML programmer.
Put it on your resume and I will take you for a beer.

A fluent, type-safe PHP library for building HTML documents using the DOM.

composer require epic-64/elem

Showcase

It reads like HTML, but it's PHP

div(id: 'hero', class: 'container')(
    h(1, text: 'Welcome'),
    p(text: 'Build HTML with pure PHP.'),
    div(class: 'actions')(
        a(href: '/start', class: 'btn btn-primary', text: 'Get Started'),
        a(href: '/docs', class: 'btn', text: 'Learn More')
    )
)

Output:

<div id="hero" class="container">
    <h1>Welcome</h1>
    <p>Build HTML with pure PHP.</p>
    <div class="actions">
        <a href="/start" class="btn btn-primary">Get Started</a>
        <a href="/docs" class="btn">Learn More</a>
    </div>
</div>

Components are just functions

function card(string $title, string $body): Element {
    return div(class: 'card')(
        h(3, text: $title),
        p(text: $body)
    );
}

// Use it anywhere
div(class: 'grid')(
    card('Fast', 'No template parsing overhead.'),
    card('Safe', 'XSS protection built-in.'),
    card('Smart', 'Full IDE support.')
)

Full power of PHP - not a crippled template language

div(class: 'user-list')(
    list_of($users)
        ->filter(fn(User $u) => $u->isActive())
        ->map(fn(User $u) => userCard($u))
)

Type-safe - your IDE and PHPStan catch mistakes

// ❌ Blade: Typo? Runtime surprise!
<a hfer="{{ $url }}">Click</a>

// βœ… Elem: Caught before you save
a(hfer: $url)  // Error: Unknown parameter "hfer"

XSS-safe by default

$evil = '<script>alert("xss")</script>';
echo div(text: $evil);
// Output: <div>&lt;script&gt;alert("xss")&lt;/script&gt;</div>

Easy conditional modifications with when()

$isAdmin = false;
$isActive = true;
div(class: 'card')
    ->when($isAdmin, fn($el) => $el->class('admin'))
    ->when($isActive, fn($el) => $el->class('active'))
// Output: <div class="card active"></div>

Layouts with slots

function page(string $title, array $head = [], array $body = []): Element {
    return html(lang: 'en')(
        head()(
            title(text: $title),
            meta(charset: 'UTF-8'),
            meta(name: 'viewport', content: 'width=device-width, initial-scale=1.0'),
            ...$head
        ),
        body()(...$body)
    );
}

page('Home', 
    head: [stylesheet('/css/app.css')],
    body: [h(1, text: 'Welcome'), p(text: 'Hello!')]
);

Output:

<!DOCTYPE html>
<html lang="en">
<head>
    <title>Home</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="/css/app.css">
</head>
<body>
    <h1>Welcome</h1>
    <p>Hello!</p>
</body>
</html>

Table of Contents

Installation

Requirements: PHP 8.4+, ext-dom

composer require epic-64/elem

Quick Start

use function Epic64\Elem\{div, p, a, span, html, head, body, title, meta, h};

// Simple elements
echo div(id: 'container', class: 'wrapper')(
    p(text: 'Hello, World!'),
    a(href: 'https://example.com', text: 'Click me')->blank(),
    span(class: 'highlight', text: 'Important')
);

// Complete HTML document
echo html(lang: 'en')(
    head()(
        meta(charset: 'UTF-8'),
        title(text: 'My Page')
    ),
    body()(
        div(id: 'app')(
            h(1, text: 'Welcome'),
            p(text: 'This is my page.')
        )
    )
);

Why Elem?

  • Type-safe - Your IDE knows what's happening. Autocomplete, refactoring, and PHPStan just work.
  • Composable - Build reusable components as plain functions. No magic, no framework lock-in.
  • Pure PHP - Full power of the language: loops, conditionals, functions, type hints.
  • XSS-safe - Text is automatically escaped through the DOM.
  • LLM-friendly - Named parameters and type checking catch AI-generated mistakes.

πŸ“– Full documentation: Why Elem? (coming soon)

Examples

Basic Elements

// Forms
form(action: '/login')(
    input(type: 'email', name: 'email')->required()->placeholder('Email'),
    input(type: 'password', name: 'password')->required(),
    button(text: 'Login', type: 'submit')
);

// Lists
ul(class: 'nav')(
    li(text: 'Home'),
    li(text: 'About')
);

// Tables
table()(
    tr()(th(text: 'Name'), th(text: 'Age')),
    tr()(td(text: 'Alice'), td(text: '30'))
);

πŸ“– Full documentation: Basic Examples

Composition & Dynamism

Use PHP's full power: enums, typed classes, functions, and native control flow.

function userCard(User $user): Element
{
    return div(class: 'user-card')(
        avatar($user->name),
        badge($user->role->value, $user->role->badge()),
        $user->active ? badge('Active', BadgeVariant::Success) : null,
    );
}

// Filter and map with full type safety
div(class: 'user-list')(
    list_of($users)
        ->filter(fn(User $u) => $u->active)
        ->map(fn(User $u) => userCard($u))
);

πŸ“– Full documentation: Composition & Dynamism

Templating & Layouts

Build reusable page layouts with multiple "slots" for content injection:

function dashboardLayout(
    string $pageTitle,
    array $headerSlot = [],
    array $mainSlot = [],
): Element {
    return pageLayout(
        pageTitle: $pageTitle,
        bodySlot: [
            div(class: 'dashboard')(
                el('header')(...$headerSlot),
                el('main')(...$mainSlot),
            ),
        ],
    );
}

// Fill only the slots you need
dashboardLayout(
    pageTitle: 'My Dashboard',
    headerSlot: [h(1, text: 'πŸš€ My App')],
    mainSlot: [card('Stats', $statsContent)],
);

πŸ“– Full documentation: Templating & Layouts

Imperative Style

While Elem encourages functional style, sometimes imperative code is clearer. Use when() for simple conditionals:

div(class: 'card')
    ->when($isAdmin, fn($el) => $el->class('admin'))
    ->when($isActive, fn($el) => $el->class('active'))

Use tap() for more complex logic:

div(class: 'user-card')
    ->tap(function ($el) use ($isAdmin, $permissions) {
        if ($isAdmin) {
            $el->class('admin');
        }
        foreach ($permissions as $perm) {
            $el->data("can-$perm", 'true');
        }
    })

πŸ“– Full documentation: Imperative Style

HTMX Integration

Return HTML fragments directly from your endpoints - no JSON serialization needed:

// Add HTMX attributes
button(text: 'Load More')
    ->attr('hx-get', '/api/items')
    ->attr('hx-target', '#results')
    ->attr('hx-swap', 'beforeend')

// Return HTML from your API
function handleSearch(string $query): void {
    $users = searchUsers($query);
    echo ul(class: 'search-results')(
        list_of($users)->map(fn($user) => 
            li(text: $user->name)
        )
    );
}

Linking External Resources

head()(
    stylesheet('/css/style.css'),
    icon('/favicon.ico'),
    font('/fonts/custom.woff2', 'font/woff2'),
    link(href: '/manifest.json', rel: 'manifest')
)

How It Works

Elem is built on PHP's native DOM extension. Each element wraps a DOMElement, and the __invoke magic method lets you add children by calling the element as a function:

// This fluent syntax...
div(class: 'card')(
    h(1, text: 'Title'),
    p(text: 'Content')
);

// ...uses __invoke to append children to the DOM

πŸ“– Full documentation: How It Works

Extending Elem

Custom Elements with el()

Use el() to create any element by tag name:

use function Epic64\Elem\el;

el('article', class: 'post')(...);
el('nav', class: 'main-nav')(...);
el('my-custom-component')->attr('some-prop', 'value');

Custom Attributes with ->attr()

// ARIA attributes
button(text: 'Menu')
    ->attr('aria-expanded', 'false')
    ->attr('aria-controls', 'menu-panel');

// Data attributes (or use ->data())
div()->data('controller', 'dropdown');

// HTMX, Alpine.js, or any other library
div()
    ->attr('hx-get', '/api/data')
    ->attr('x-data', '{ open: false }');

Raw HTML with raw()

When you have trusted HTML from an external source (Markdown parser, CMS, sanitizer):

use function Epic64\Elem\raw;

$html = $markdownParser->convert($markdown);
div(class: 'prose')(raw($html));

⚠️ Never use raw() with user input - it bypasses XSS protection.

Adding Text to Elements

There are three ways to add text content:

use function Epic64\Elem\text;

// 1. Using the text: parameter
p(text: 'Hello, World!');

// 2. Using plain strings as children
p()('Hello, World!');

// 3. Using text() for explicit text nodes
p()(text('Hello, World!'));

All three methods automatically escape content for XSS protection.

API Reference

Element Classes

All element classes extend the base Element class and provide fluent interfaces:

  • Structure: Html, Head, Body, Title, Meta, Link, Style, Script
  • Text: Div, Span, Paragraph, Heading
  • Links & Media: Anchor, Image
  • Forms: Form, Input, Button, Label, Textarea, Select, Option
  • Lists: UnorderedList, OrderedList, ListItem
  • Tables: Table, TableRow, TableCell, TableHeader
  • Special: RawHtml - Holds unescaped HTML content (use via raw() function)

Common Methods

All elements support:

  • ->id(string $id) - Set the id attribute
  • ->class(string ...$classes) - Add CSS classes
  • ->attr(string $name, string $value) - Set any attribute
  • ->style(string $style) - Set inline styles
  • ->data(string $name, string $value) - Set data-* attributes
  • ->tap(callable $callback) - Tap into the element for imperative modifications
  • ->when(bool $condition, callable $callback) - Conditionally apply modifications
  • ->toHtml(bool $pretty = false) - Output HTML
  • ->toPrettyHtml() - Output formatted HTML (called automatically in __toString)

Helper Functions

  • el(string $tag) - Create a generic element with any tag name
  • raw(string $html) - Create a RawHtml instance for injecting unescaped HTML
  • list_of(iterable $items) - Create a fluent collection for mapping/filtering

Demo Server

The examples/ directory contains interactive demos showcasing the library's features.

Running the Demo Server

# From the project root
php -S localhost:8080 -t examples examples/server.php

Then open http://localhost:8080 in your browser.

Available Demos

  • Index (/) - Overview and navigation
  • Layout Demo (/layout-demo) - Complex templates with multiple slots: page layouts, dashboard layouts, cards, and modals
  • Dynamic Content Demo (/dynamic-content-demo) - Showcases enums, reusable components, data transformation, and conditional rendering
  • Template Demo (/template-demo) - Building complete HTML pages
  • HTMX Demo (/htmx-demo) - Interactive components with HTMX integration

Development

Running Tests

# Run tests
vendor/bin/pest

# Run tests with coverage
vendor/bin/pest --coverage

# Run tests with coverage and enforce minimum threshold
vendor/bin/pest --coverage --min=80

Static Analysis

vendor/bin/phpstan analyze

License

MIT