helios-ag/fm-bbcode-bundle
Symfony bundle that integrates the FM BBCode parser, making it easy to parse and render BBCode in your applications. It provides a configurable service and Twig filter for converting BBCode to HTML.
Read this first: helios-ag/fm-bbcode-bundle is a Symfony bundle, not a Laravel package. It ships a Bundle class, DI extension, and YAML config — none of which PackageManifest will ever load. The 7.0 release ("Symfony 3 without any deprecation messages") confirms that: it is a Symfony 3 compatibility bump, not a modernisation. Treat the project as two separate artifacts:
helios-ag/fm-bbcode — the underlying parser library. This is the part you actually want.FMBbCodeBundle — a thin DI/Twig wrapper. Use it only as a reference for the default tag set, service wiring, and config keys.Minimal path for a Laravel app:
composer require helios-ag/fm-bbcode
Before you commit, verify it installs against your PHP version:
composer why-not helios-ag/fm-bbcode
7.0 targets Symfony 3, which means Symfony components pinned to ^3.0 and a PHP floor in the 5.5–7.x range. On a PHP 8.1+ host Composer will almost certainly refuse, and that is the correct outcome — do not work around it on the bundle side. If you must stay on this library, your options in order of preference: pin it in a fork with widened php/symfony/* constraints and a test suite to back it, run it in a separate microservice, or drop it for s9e/TextFormatter (actively maintained, Laravel-friendly, same problem space). Do not just add --ignore-platform-reqs and hope.
Where to look first in the bundle repo:
DependencyInjection/Configuration.php — the canonical list of options (default tags, emoticons, allowed schemes). 7.0 is the release where the Symfony 3 deprecation notices were cleared, so on that tag this file is the cleanest version of the contract to copy from.Resources/config/*.yml — the exact tag names and any bundled smiley/emoticon definitions. Copy these into config/bbcode.php; they're the only part of the bundle that's genuinely reusable.CodeDefinition / Validator interfaces — that's your extension surface.First real use case: rendering user-submitted forum posts and comments. Wire it once through a small wrapper class and a service provider:
// app/Support/Bbcode/Renderer.php
namespace App\Support\Bbcode;
class Renderer
{
public function __construct(protected array $profiles, protected string $default = 'comment') {}
public function render(?string $text, ?string $profile = null): string
{
if ($text === null || $text === '') {
return '';
}
$parser = ParserFactory::make($this->profiles[$profile ?? $this->default]);
return app(Sanitizer::class)->clean($parser->parse($text)->getAsHtml());
}
}
// app/Providers/BbcodeServiceProvider.php
public function register(): void
{
$this->mergeConfigFrom(config_path('bbcode.php'), 'bbcode');
$this->app->singleton(Renderer::class, fn ($app) => new Renderer(
profiles: $app['config']['bbcode.profiles'],
default: $app['config']['bbcode.default'],
));
}
Build a fresh parser per render (see Octane note below), configured from your profile list rather than the library's "all tags on" default.
The bundle's biggest practical lesson is that tag sets are configuration, not code. Mirror it — but with multiple profiles so a signature field can't embed [img] or [youtube]:
// config/bbcode.php
return [
'default' => 'comment',
'profiles' => [
'comment' => ['bold', 'italic', 'underline', 'link', 'quote', 'code'],
'forum' => ['bold', 'italic', 'underline', 'link', 'quote', 'code', 'img', 'list', 'color'],
'signature' => ['bold', 'italic', 'link'],
],
'allowed_schemes' => ['http', 'https', 'mailto'],
];
Then pick the profile at the call site: $renderer->render($post->body, 'forum'). This is the single highest-value decision you can make with a BBCode parser — an over-broad allowlist is how you get stored XSS via [url=javascript:...] and [img] beacons.
For anything product-specific — [spoiler], [mention], [youtube=ID] — subclass the definition type and attach a Validator that rejects bad input before it hits the parser output:
final class YoutubeDefinition extends CodeDefinition
{
protected function parse(): void
{
$id = preg_match('~^[A-Za-z0-9_-]{11}$~', $this->getAsText()) ? $this->getAsText() : null;
$this->setAsHtml($id
? sprintf('<div class="video"><iframe src="https://www.youtube-nocookie.com/embed/%s"></iframe></div>', e($id))
: e($this->getAsText()));
}
}
Two rules here: always e() interpolated text (the parser does not know what's inside your custom tag), and fall back to escaped plain text rather than throwing — a malformed tag shouldn't 500 a forum thread.
Store raw BBCode in the DB; render at output time. Do not store HTML unless you're prepared to re-render the entire table when you change a tag definition.
For listings (forum index, comment streams), rendering is the N+1 cost — cache on the body hash:
public function renderedBody(): string
{
return Cache::remember(
'bb:'.md5($this->body).':'.$this->profile,
now()->addDay(),
fn () => app(Renderer::class)->render($this->body, $this->profile),
);
}
Expose it as a plain method, not an accessor in $appends — you want rendering on the detail view, not automatically on every toArray().
@php /** @var \App\Models\Post $post */ @endphp
{!! $post->renderedBody() !!}
Wrap it in a directive so nobody forgets the sanitizer:
Blade::directive('bbcode', fn ($expr) => "<?php echo e(app(\App\Support\Bbcode\Renderer::class)->render({$expr})); ?>");
e() around the rendered output would double-escape your <strong> tags, so don't. The directive above escapes the input; the renderer returns trusted, sanitized HTML.
A FormRequest rule that rejects unbalanced markup upfront gives users a real error instead of silent mangling:
'body' => ['required', 'string', 'max:20000', new ValidBbcode('forum')],
Have the rule parse and assert no exceptions plus no disallowed tag names. Cheap, and it keeps junk out of the DB.
If you ever move to Markdown (likely, in 2025), the migration is a swap:
interface MarkupRenderer { public function render(?string $text, ?string $profile = null): string; }
Bind BbcodeRenderer to it now, and the call sites never learn which parser you use.
The package is effectively abandoned. Last release 2016-03-13, 55 stars, no dependents, license field is NOASSERTION (i.e. Packagist couldn't parse a recognized license). 7.0 is a Symfony 3 deprecation sweep from that same era — it is a maintenance blip on a dead line, not a revival. For a commercial product, resolve the license question from the repo's composer.json/LICENSE before shipping, and budget for a fork. Assume no security patches.
PHP/Symfony version constraints will bite — 7.0 tells you exactly how. The release note is the most precise compatibility statement the project ever made: Symfony 3, deprecation-clean. Symfony 3 went EOL in 2018, so expect symfony/http-kernel: ^3.0 and a PHP ceiling below 8. Check the library's composer.json separately — it may be looser than the bundle's — and don't pull the bundle into your app just to get the library, or Composer will drag in symfony/http-kernel and friends for no reason. If a future 8.x lands, re-read its note before assuming anything; historically each major was just a framework-bump.
Octane / long-lived workers. If the parser instance retains state from parse(), reusing a singleton across requests leaks one user's text into another's render. Build the parser per call (or clone a configured prototype and reset it) and add an Octane test that renders two different payloads in sequence and asserts isolation.
Sanitize after parsing, always. The parser emits HTML for tags you enabled; it is not an HTML sanitizer. Pipe the result through mews/purifier or ezyang/htmlpurifier with an allowlist matching your profiles. Pay specific attention to:
javascript:/data: in [url] and [img] — validate schemes explicitly.style attribute injection via [color=...] and [size=...] — prefer class-based output over inline styles.[html]-style tag is enabled — leave it off.Escaping order. Decide once: either the parser escapes user text (usual) or you escape first and disable its escaping. Doing both gives you &amp; in every URL query string; doing neither gives you XSS. Write one test with &, <, ", and an emoji to pin the behavior.
[code] and [noparse] are where regex parsers break. Verify that BBCode inside a code block isn't interpreted, and that emoticon replacement doesn't fire inside [code] — that's the classic smiley-inside-a-code-sample bug. Test [code][b]not bold[/b][/code].
Nesting and malformed input. Unclosed tags, deeply nested [quote] (100 levels of recursion), and mismatched [b][i][/b][/i] should degrade gracefully, not blow the stack. Fuzz it with a few nasty strings before launch; add a depth cap in your custom definitions if the library doesn't impose one.
Emoticon ordering. Replace smileys after parsing tags, not before, or [code]:)[/code] gets mangled. Keep the smiley map in config, not in the DB, so you can change it without a migration.
Performance. These parsers are regex-heavy — expect tens of microseconds to low milliseconds per document. Fine per detail page, painful for a 50-item thread listing. Cache, and consider rendering only the first N posts server-side.
Extension points worth knowing: custom CodeDefinition subclasses, Validator implementations for input checking, and any filter/hook the version you install exposes (addFilter-style). If there's no sanitizer hook, put it in your wrapper — never rely on the library to be the security boundary. Note that 7.0's "no deprecations" work is in the bundle's DI layer, which you aren't loading; it changes nothing about the parser's extension surface.
Testing strategy that actually catches regressions: snapshot the rendered HTML for a fixture corpus (one doc per tag, plus nesting, malformed, and XSS-attempt cases) and diff on upgrade. That's how you'll find out a fork changed [url] handling — and it's the only safety net you get, since upstream won't be testing against your PHP.
How can I help you explore Laravel packages today?