machy8/macdom
Macdom is a Laravel package for building and manipulating HTML elements in PHP. It provides a fluent, macroable API for creating nested markup, attributes, and dynamic content, helping keep Blade templates cleaner and easier to maintain.
Macdom is a standalone PHP HTML preprocessor — it lets you define your own HTML-like tags and attributes and expands them into real markup before the browser (or a downstream template engine) ever sees them. It predates and is unrelated to Laravel's Blade components, so treat it as a build/compile step, not a runtime templating engine.
Install and pin:
composer require machy8/macdom:^1.0
The last release was October 2017, so the constraint solver will happily pull something written against PHP 7.0/7.1. Pin an exact version, commit the lock file, and immediately run your test suite on your current PHP (8.x) to surface deprecations before you build anything on top of it.
Where to look first, in this order:
README.md and the docs site (https://machy8.github.io/Macdom/) — the tag-definition syntax and the processing pipeline are the only two things you actually need to learn.src/ — find the engine entry point and the class representing a registered tag/handler. That class is your extension point; everything else is plumbing.tests/ — the fixtures are the most reliable documentation for edge cases (nesting, attribute quoting, self-closing tags) that the README glosses over.First use case — one custom tag, end to end:
use Macdom\Engine; // verify the exact FQCN against src/ before committing
$engine = new Engine();
$engine->addTag('alert', function (array $attributes, string $content): string {
$type = $attributes['type'] ?? 'info';
return sprintf('<div class="alert alert-%s">%s</div>', $type, $content);
});
echo $engine->preprocess('<alert type="warning">Disk almost full</alert>');
// <div class="alert alert-warning">Disk almost full</div>
Get that working in a tinker session or a throwaway route before touching Blade, service providers, or caching. The API names above are illustrative — confirm them against src/ early, because the docs site is the only prose reference and it may lag the code.
Centralize tag definitions in a service provider. Register one singleton engine in a dedicated MacdomServiceProvider, and register all tags in a single, readable method. Scattering addTag() calls across controllers is the fastest way to get non-deterministic output — the engine is stateful, so the set of registered tags is the configuration.
$this->app->singleton(Engine::class, function () {
$engine = new Engine();
foreach (config('macdom.tags') as $name => $handler) {
$engine->addTag($name, $this->app->make($handler));
}
return $engine;
});
Run Macdom as a response-stage middleware, not inside Blade. The cleanest integration is to let Blade render normally and pipe the finished HTML through Macdom in a middleware or a Response macro. Doing it inside a Blade directive creates a two-pass parsing problem: Blade eats {{ }} and @ before Macdom ever sees them, and Macdom may re-interpret Blade's output. One pass each, in a known order, is far easier to reason about.
public function handle($request, Closure $next)
{
$response = $next($request);
if ($this->shouldPreprocess($response)) {
$response->setContent(app(Engine::class)->preprocess($response->getContent()));
}
return $response;
}
Guard that middleware with a content-type check and an allow-list of routes. Preprocessing a JSON response or a streamed download is a silent corruption bug.
Use it as a static build step where possible. The package's sweet spot is content authored by people who know HTML but not PHP: <partial src="pricing-table">, <include file="faq">, <cta href="..." label="...">. If your site has a build pipeline (Jigsaw, Sculpin, a custom Artisan command), preprocess files on disk and cache the output. That removes Macdom from the request path entirely.
Back tags with Laravel views when you need real logic. A tag handler is just a callable returning a string, so it can render a Blade partial:
$engine->addTag('card', fn ($attrs, $content) => view('components.card', [
'title' => $attrs['title'] ?? null,
'slot' => $content,
])->render());
This gives designers a tag vocabulary while keeping escaping, localization, and authorization in code you already know how to test.
Validate attributes at the boundary. Handlers receive raw string attributes. Cast, default, and reject explicitly — a missing type should fall back, and an unknown one should throw in local/testing and degrade in production. Do not let an unvalidated attribute flow into a class name or a URL.
Test with golden files. Because the output is pure text, the highest-value test is a fixture directory: tests/fixtures/macdom/{input,expected}/*.html, asserted with a whitespace-normalizing comparison. Add a fixture for every gotcha you hit — nesting, unquoted attributes, self-closing tags — because those are exactly the behaviors that will silently change if you ever fork or upgrade.
It is effectively abandoned. Last release 2017, 11 stars, one maintainer. Budget for the possibility that you own this code. Before adopting, spend twenty minutes checking the source for PHP 8 incompatibilities (each(), create_function(), dynamic properties, {}-offset string access). If you commit, fork it into your org, add a PHP 8 CI matrix, and tag your own release. If you cannot, reach for twig/twig, masterminds/html5, or plain Blade partials instead.
Processing order is not incidental. Nested and recursive tags depend on the engine's expansion order — a tag that emits another custom tag needs that inner tag handled in a later pass, and whether Macdom does that automatically is exactly the kind of thing the 2017 docs do not state clearly. Write a fixture with three levels of nesting on day one and pin the observed behavior. Similarly, if a handler emits a tag name that is also registered as an input tag, you can create an infinite expansion loop — cap recursion or namespace generated tags.
Whitespace and quoting are the classic failure mode. Hand-written HTML from content authors will contain unquoted attributes, single quotes, stray > inside attribute values, and self-closing <tag />. Test each. If the engine's parser is stricter than a browser's, add a lint step that rejects malformed input with the file and line number rather than letting the output silently degrade.
Do not treat it as a sanitizer. Macdom is a preprocessor: it concatenates strings. A tag handler that interpolates user input into the returned HTML is an XSS vector, and nothing in the pipeline will stop it. Sanitize at the edges (HTMLPurifier, e(), Blade's {{ }}) and never preprocess user-submitted HTML without a whitelist of allowed tags.
Singleton state leaks. In Laravel Octane, or in queue workers processing many jobs, a singleton engine accumulates any tags registered conditionally during a request. Either register everything unconditionally at boot, or rebuild the engine per unit of work. This is a subtle, hard-to-reproduce bug — the second request succeeds where the first failed.
Cache aggressively, key on config. Parsing is the expensive part. Cache the expanded HTML keyed on hash('xxh128', $source . json_encode(config('macdom.tags'))) (or the file mtime for static builds). Invalidate on deploy. Never cache per-user preprocessed output unless the tags are provably user-independent — a <user-card> tag will happily embed someone else's name into the cache and serve it to the next visitor.
Debugging workflow: when a tag misbehaves, shrink the input to a three-line snippet, disable all caching, and dump the source immediately before and after preprocess(). Most "the tag didn't expand" reports are an unregistered tag name, a case mismatch, or a whitespace character inside the tag name from a WYSIWYG editor. Log the registered tag names once at boot — it saves an hour on the first day.
Extension points worth knowing: the tag-registration API is the only stable one. Keep each handler a pure function of (attributes, content) returning a string, with no dependency on request globals or database reads — that makes them trivially unit-testable and safe to run inside a cache-warming build. Everything else in the package (internals, parser classes, exception types) should be considered private and subject to change without notice.
Have an exit plan. Because the tag syntax lives in your templates, a future migration to Blade components or Twig is a mechanical rewrite — but only if you kept the handlers small and the tags semantic (<card>, <alert>) rather than leaking layout into them. Design the vocabulary so it survives the library.
How can I help you explore Laravel packages today?