grogy/linkify-bundle
Grogy Linkify Bundle converts plain text into HTML by detecting URLs and email addresses and turning them into clickable links. A small, configurable helper for adding automatic linkification to text output in your PHP or Symfony application.
Step 0 — run the kill test before anything else. In a throwaway branch of your Laravel app:
composer require grogy/linkify-bundle --dry-run
A Symfony 2-era bundle pins the legacy symfony/symfony ~2.x monolith and PHP 5.x/7.x ranges. Expect a conflict dump against Laravel 10/11/12 on PHP 8.2+. That failure is the cheapest signal you will get — record it in the ticket and stop. Do not reach for --ignore-platform-reqs; you would only be proving the package installs, not that it works.
Where to look first (if you're evaluating the code, not installing it). Clone the repo and read it in this order:
*Bundle.php — the Symfony bundle class. If it only registers an extension and loads a service definition, it contains no logic and is safe to ignore forever.DependencyInjection/Configuration.php — this is where the package's real feature set lives. It tells you the actual configurable surface: link attributes (target, rel, class) and the email-obfuscation toggle are the advertised ones.Resources/config/services.yml — the service ID and its constructor arguments. This is the "~100 lines of glue" you'd otherwise write.Linkifier/Linkify service). This is the only file with transferable value. It is a regex or two wrapped in a preg_replace_callback.If step 4 is under ~150 lines — which it almost certainly is — you have now read the entire package. Porting it is a morning's work.
First use case. Comment bodies and support-ticket text: free-text fields where users paste URLs and email addresses and expect them to be clickable. This is the lowest-risk entry point because the content is plain text (no HTML to mangle) and the output is short. Chat transcripts and profile bios are the natural follow-ons. Anything involving Markdown, WYSIWYG output, or stored HTML is a later problem and requires the sanitization ordering described below.
Minimum viable Laravel shape. Whatever you decide about the bundle, the artifact you actually ship is one class:
final class Linkifier
{
public function toHtml(string $text): string
{
// escape first, then linkify the escaped text
return $this->linkifyTextNodes(e($text));
}
}
Bind it in a service provider, resolve it in a controller or Blade directive, and you are done. The bundle's contribution to that class is the regex — everything else is Laravel wiring it cannot provide.
Pattern 1 — the extract-and-wrap (the realistic path). Copy the linkification routine out of the bundle into App\Support\Linkifier, keep the original license header, and note the provenance in the class docblock. Wrap it behind an interface (Linkifier::toHtml) so a maintained library can replace it later without touching call sites. This is the pattern to use if you have read the source and like its behavior; it gives you the function without the dependency graph.
Pattern 2 — replace with a maintained linkifier. Take a framework-agnostic library (a standalone linkify implementation, misd/linkify, league/commonmark's autolink extension if you already render Markdown, or s9e/TextFormatter if you need a full UGC formatter) as a direct Composer dependency. Wrap it in the same Linkifier interface. Same call sites, same Blade directive, live dependency. For most Laravel teams this is the correct default, and the bundle never enters composer.json.
Pattern 3 — write-time linkification. Linkify once when the row is created and store the rendered HTML in a body_html column beside the raw body. Read paths then render a plain {!! $comment->body_html !!}. Benefits: no regex on every page view, no ReDoS on the read hot path, output is stable and reviewable, and regeneration is a queue job rather than a request-time surprise. This is the right pattern for high-traffic feeds. It requires a backfill migration and a re-linkify job when you change link policy (e.g. adding rel="ugc").
Pattern 4 — render-time with a Blade directive. For low-traffic or admin-only surfaces, a directive is the least ceremony:
Blade::directive('linkify', fn ($expr) => "<?php echo app(\App\Support\Linkifier::class)->toHtml($expr); ?>");
Then @linkify($comment->body). Note this is a plain echo of generated HTML — the contract is that toHtml returns safe output, which means it must escape the input itself (see Pattern 5). Prefer Blade::stringable() or a formatter hook if you want it to compose inside {{ }}; the directive is the simplest thing that works but makes the trust boundary less obvious at the call site.
Pattern 5 — sanitize → linkify text nodes → re-validate. This is non-negotiable for any input that might contain markup:
mews/purifier or HTMLPurifier).href attributes and inside existing <a> tags, producing nested anchors and broken markup.For plain-text fields (comments, ticket bodies) step 1 is just e(), which is why plain text is the recommended starting scope. If you cannot cleanly separate text nodes from markup, linkify the plain text first and then render through your sanitizer — never the reverse. Linkify is not a sanitizer and cannot be used as one.
Pattern 6 — configuration as a first-class object. The bundle's configurable surface (link attributes, email obfuscation) is real and worth keeping. Put it in config/linkify.php:
return [
'attributes' => ['rel' => 'nofollow ugc noopener', 'target' => '_blank'],
'allowed_schemes' => ['http', 'https', 'mailto'],
'obfuscate_emails' => true,
'max_length' => 50_000, // input cap, see Gotchas
];
Inject it into the Linkifier constructor. This is the piece the bundle would have given you in Symfony and cannot give you in Laravel, and it's the piece that lets you change link policy without editing regexes.
Pattern 7 — feature-flag the rollout. Put the new rendering behind a config/flag so you can fall back to plain escaped text. Linkification changes output shape (new <a> elements), which means CSS, HTML diffs in tests, and search indexing all shift. A kill switch costs ten lines and saves an incident.
Integration tips.
booted() model hook rather than scattering calls through controllers.LinkifyServiceProvider) so it can be conditionally loaded in tests.toHtml in tests as an exact string, not with assertSeeText. You want the rel/target attributes pinned.The framework mismatch is the headline gotcha. This is a Symfony 2 bundle: a Bundle class, DI service definitions, and (probably) a Twig extension. Laravel has none of those extension points. There is no service provider, no config/linkify.php to publish, no facade, no Blade directive. Any "getting started" that implies php artisan vendor:publish is wrong. The only day-to-day leverage you get is the regex, extracted.
Naive regex over HTML is an XSS primitive. If the input can contain <, a linkifier that doesn't understand markup will happily place an anchor inside an attribute or inside an existing <a>. Sanitize after linkifying, or linkify text nodes only, and always re-validate. This is the highest-severity failure mode of the entire feature and the one thing worth writing a test for before shipping anything.
Enforce a scheme allowlist. http, https, mailto — nothing else. A regex that matches "anything followed by ://" will happily turn javascript: and data: into clickable anchors. This must be an explicit allowlist in code, not an implicit assumption about the regex.
Cap input length and watch for ReDoS. Regex-based linkifiers are classic catastrophic-backtracking targets on adversarial input (long strings with many near-miss URL prefixes). Cap the input (e.g. 50KB) and reject or truncate beyond it. On read-path rendering, a pathological comment can pin a PHP worker. Linkifying at write time (Pattern 3) moves this off the request path entirely.
Trailing punctuation and balanced parentheses. See https://example.com/foo. must not produce href="https://example.com/foo.". The same applies to ,;:!? and closing parens: (https://example.com) should link the URL without the ), but https://en.wikipedia.org/wiki/Foo_(bar) should keep its parens. This is the single most common correctness bug in homegrown linkifiers and a dead package will never fix it for you. Write the test cases first.
Existing anchors and code blocks will double-link. If you ever linkify a string containing markup, guard against matching inside <a>...</a>, <code>, and <pre>. Naive preg_replace_callback produces nested anchors, which browsers repair in ways that break your layout.
Emails auto-linked are harvestable. The bundle advertises email obfuscation — treat that as a real requirement, not a nicety. Options: HTML-entity-encode the address, emit a JS-assembled mailto:, or simply don't linkify emails on public pages and require an explicit opt-in per surface. Decide per use case; "we linkify emails everywhere" is a spam-magnet default.
No maintenance signal means you own every future bug. Zero stars and zero dependents means no one else is running this code path, no bug reports, and no CVE feed. If you fork it, that ownership is permanent, and every PHP or Laravel upgrade re-opens the "does our fork still work" question. If you replace it, that question disappears. State this explicitly in the design doc rather than absorbing it silently.
Debugging technique for bad output. When a URL comes out wrong, don't stare at the regex. Dump the input string with bin2hex to catch smart quotes, non-breaking spaces, and zero-width characters that users paste from word processors — these are the cause of most "it didn't linkify" reports, and they'll never show up in a rendered HTML diff.
Unicode and IDN are unsolved. Emoji adjacent to links, internationalized domain names, and non-ASCII path segments are all edge cases an abandoned regex will not handle correctly. If your users paste non-English URLs, treat this as a known limitation and test it explicitly rather than discovering it in production.
Empty/null input must render nothing, not throw. toHtml(null) should return ''. Guard at the top of the method; preg_replace_callback on null is a deprecation in PHP 8.1+ and an error in stricter setups.
Extension points worth keeping when you port. The attribute configuration (rel/target/class), the email-obfuscation toggle, and the scheme allowlist are the three knobs your team will actually turn. Everything else in the bundle is Symfony wiring you should leave behind. Build those three into config/linkify.php from day one and the replacement is a permanent drop-in.
How can I help you explore Laravel packages today?