pk/markdownify-bundle
Symfony bundle that wires Markdownify into your app, converting Markdown to HTML through a ready-to-use service and Twig filter. Configure the converter once and render Markdown content cleanly in templates.
This bundle is a very thin Symfony DI wrapper around the markdownify PHP library (HTML → Markdown converter). There is no business logic in the bundle itself — its only job is to register a Markdownify converter as a container service. Treat it as a convenience, not a framework component.
1. Install
composer require pk/markdownify-bundle
The last tagged release is from 2019 and the repo is archived. Check that the transitive markdownify constraint still resolves on your PHP version before committing to it:
composer why markdownify/markdownify # or whatever the underlying package is named
composer show pk/markdownify-bundle
If resolution fails on PHP 8.x, stop here and use league/html-to-markdown directly — you lose nothing, because the bundle contributes one service definition.
2. Register the bundle
Symfony ≤ 3.x, in app/AppKernel.php:
$bundles = [
// ...
new Pk\MarkdownifyBundle\PkMarkdownifyBundle(),
];
Symfony 4+ (Flex has no recipe for this, so it is manual), in config/bundles.php:
return [
// ...
Pk\MarkdownifyBundle\PkMarkdownifyBundle::class => ['all' => true],
];
Confirm the namespace by opening PkMarkdownifyBundle.php in vendor/pk/markdownify-bundle/ — the class name and namespace are the source of truth, not the README.
3. Find the service ID — do this before writing any code
There is no documented, stable service ID. The bundle predates autowiring and public-by-default services, so discover it:
bin/console debug:container markdown --show-private
# or, if no match:
bin/console debug:container --show-private | grep -i markdown
Then open vendor/pk/markdownify-bundle/Resources/config/services.xml (or .yml) and read the <service id="..." class="..."> element. That file is your actual documentation.
4. First use case
The canonical use is exporting stored HTML (CMS article bodies, rendered emails, HTML reports) as Markdown files or pasted text. A minimal command:
// src/Command/ExportMarkdownCommand.php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ExportMarkdownCommand extends Command
{
protected static $defaultName = 'app:export-markdown';
public function __construct(private Markdownify\Converter $converter)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$html = '<h1>Hello</h1><p>Some <strong>bold</strong> text.</p>';
$output->writeln($this->converter->parseString($html));
return Command::SUCCESS;
}
}
If constructor injection doesn't resolve, alias the discovered service ID in config/services.yaml:
services:
Markdownify\Converter:
alias: markdownify # ← replace with the real ID from step 3
public: true
Note the method name: the library exposes parseString() (and often parseFile()); there is no convert().
Inject the library class, not the bundle's service ID. Alias the service to Markdownify\Converter (or ConverterExtra if you need the extended feature set) once in services.yaml, then type-hint the class everywhere. This gives you autowiring, IDE completion, and a one-line swap to a maintained replacement later.
Twig filter. The bundle ships no Twig integration. Add a small extension — this is the highest-value piece of glue you'll write:
namespace App\Twig;
use Markdownify\Converter;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class MarkdownifyExtension extends AbstractExtension
{
public function __construct(private Converter $converter) {}
public function getFilters(): array
{
return [new TwigFilter('markdownify', [$this, 'toMarkdown'], ['is_safe' => ['html']])];
}
public function toMarkdown(string $html): string
{
return $this->converter->parseString($html);
}
}
Register with autoconfigure: true and the filter appears as {{ post.body|markdownify }}. Only mark it is_safe if you trust the input, since the output passes through unescaped.
Bulk export / CLI job with per-item error isolation. One malformed HTML document can emit a DOMDocument warning or throw inside the converter. Wrap each document separately so one bad row doesn't abort the run, and log the identifier:
foreach ($repository->iterateAll() as $row) {
try {
$md = $this->converter->parseString($row->getBody());
$filesystem->write($targetDir . '/' . $row->getSlug() . '.md', $md);
} catch (\Throwable $e) {
$this->logger->error('markdownify failed', ['id' => $row->getId(), 'e' => $e]);
}
}
Cache the result. Conversion is DOM parsing plus string walking — cheap for one page, expensive across a listing. Since this is a pure function of the input HTML, cache by content hash rather than by entity ID:
$key = 'md_' . hash('xxh128', $html);
return $this->cache->get($key, fn () => $this->converter->parseString($html));
This also survives edits to unrelated fields.
Decorate rather than monkey-patch. If you need to post-process output (e.g. rewrite relative links to absolute URLs, strip tracking pixels), wrap the service in a decorator and keep the original untouched:
services:
App\Markdown\HtmlToMarkdown:
decorates: markdownify
arguments: ['@.inner']
Use it as a data boundary, not a live renderer. The natural workflow pattern is: HTML is the storage format, Markdown is a derived export produced on demand (download button, API ?format=md, documentation build). Do not store the Markdown output as the source of truth — you'd be coupling your database to an archived library.
The archive status is the headline. No security fixes, no PHP 8.x maintenance, no Symfony 6/7 compatibility. The bundle contributes one service definition; forking it is trivial and often the right call. If you're picking a stack today, league/html-to-markdown covers the same ground and is maintained — the migration is a constructor type-hint swap plus a method-name change.
Private services will bite you on Symfony 4+. The bundle's XML predates the public: false default change and may have been written assuming a public container. If $container->get('markdownify') throws ServiceNotFoundException in a controller, that's the cause — alias it in YAML as shown above rather than making it public.
No Configuration class means no config/packages/pk_markdownify.yaml. Any markdownify: block you write will be silently ignored (or, with strict_mode, throw). Customization must go through a compiler pass, a decorator, or service definition overrides in your own services.yaml.
Charset and malformed HTML. The underlying converter typically wraps input in a DOMDocument. Prepend a <meta charset="utf-8"> or ensure your stored HTML declares one, or you'll get mojibake in accented characters. DOMDocument::loadHTML() is forgiving but noisy — if your logs fill with Tag ... invalid warnings, set libxml_use_internal_errors(true) around the call, or pre-clean with a HTML5 parser.
Whitespace fidelity is not Markdownify's strength. Expect inconsistent blank lines around nested lists, blockquotes, and tables. Normalize output (collapse 3+ newlines, trim trailing spaces) before writing files or committing to a docs repo, or your diffs will churn on every regeneration.
The conversion is lossy and asymmetric — it is not round-trippable. Styles, classes, inline CSS, and <div> layout structure disappear entirely. Don't design a feature that assumes HTML → Markdown → HTML returns the original; keep the HTML as the canonical artifact.
Debugging: bypass the container first. To determine whether a bug is in the bundle or the library, instantiate the converter directly in bin/console or a scratch test:
$c = new Markdownify\Converter();
echo $c->parseString('<h1>x</h1>');
If that fails, the bundle is irrelevant — you're debugging the vendor library, and an archived one at that.
Testing. Because the service is stateless and pure, mock it with a willReturn('expected markdown') stub in controller/command tests rather than exercising real conversion. Keep one integration test that asserts a small, fixed HTML snippet converts to a fixed string, so a future library swap is caught immediately.
Version pinning. Add "pk/markdownify-bundle": "0.1.*" (or whatever the real constraint is) plus a comment in composer.json noting the archive date. Without dependents and without maintainers, an unattributed ^1.0 range that resolves to something odd in 18 months is a hard debugging session.
How can I help you explore Laravel packages today?