koh / sylius-markdown
Accept: text/markdown content negotiation for Sylius, rendering entity data through templates rather than converting rendered HTML.
Package info
github.com/ohartwig/sylius-markdown
Type:sylius-bundle
pkg:composer/koh/sylius-markdown
Requires
- php: ^8.5.11
- koh/sylius-agent-contracts: ^1.0
- league/html-to-markdown: ^5.1.4
- sylius/sylius: ^2.2.9 || ^2.3.0
- symfony/config: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/event-dispatcher: ^7.4 || ^8.0
- symfony/http-foundation: ^7.4 || ^8.0
- symfony/http-kernel: ^7.4 || ^8.0
- twig/twig: ^3.30.0
Requires (Dev)
- koh/dev-sylius: ^1.0
- phpstan/phpstan: ^2.3.1
- phpunit/phpunit: ^11.5.57
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- v1.1.40
- v1.1.39
- v1.1.38
- v1.1.37
- v1.1.36
- v1.1.35
- v1.1.34
- v1.1.33
- v1.1.32
- v1.1.31
- v1.1.30
- v1.1.29
- v1.1.28
- v1.1.27
- v1.1.26
- v1.1.25
- v1.1.24
- v1.1.23
- v1.1.22
- v1.1.21
- v1.1.20
- v1.1.19
- v1.1.18
- v1.1.17
- v1.1.16
- v1.1.15
- v1.1.14
- v1.1.13
- v1.1.12
- v1.1.11
- v1.1.10
- v1.1.9
- v1.1.8
- v1.1.7
- v1.1.6
- v1.1.5
- v1.1.4
- v1.1.3
- v1.1.2
- v1.1.1
- v1.1.0
- dev-main / 1.0.x-dev
- v1.0.0
This package is auto-updated.
Last update: 2026-10-13 21:18:49 UTC
README
koh/sylius-markdown
Accept: text/markdown content negotiation for the Sylius storefront.
An agent asking for Markdown gets Markdown; a browser gets the page it always got.
Install
composer require koh/sylius-markdown
// config/bundles.php Koh\SyliusMarkdown\KohSyliusMarkdownBundle::class => ['all' => true],
Configure
# config/packages/koh_markdown.yaml koh_markdown: enabled: true max_age: 3600 products: true # serve Markdown for product pages
Two decisions worth knowing about
Markdown is built from entities, not from rendered HTML
Converting the finished page is the shortcut, and it produces a document full of navigation, footer, cookie banner and related-product carousels — furniture an agent has to discard, if it can tell it apart from the content at all. Each resource type gets a template over its entity instead:
templates/product.md.twig
Override it in your application at
templates/bundles/KohSyliusMarkdownBundle/product.md.twig.
HTML still appears in one place: rich-text fields are authored as HTML in the
admin. Those single fields go through league/html-to-markdown via the
markdown_from_html filter — converting a field, not a layout.
The controller never runs
The listener sits on kernel.request and short-circuits with setResponse(),
so the controller and its whole Twig render are skipped. The alternative —
render HTML, then throw it away — pays for the full page on every agent hit.
Priority 4 puts it below Symfony's firewall (8). Authentication has already
run, so a Markdown request for /account/… is subject to the same access
control as the HTML one. Above the firewall it would be an authentication
bypass.
Negotiation
The naive check (str_contains($accept, 'text/markdown')) gets two real cases
wrong, so the header is parsed per RFC 9110:
| Accept | Result | Why |
|---|---|---|
text/markdown |
Markdown | |
text/markdown;q=1.0, text/html;q=0.9 |
Markdown | preferred |
text/html;q=1.0, text/markdown;q=0.5 |
HTML | merely tolerated |
text/markdown;q=0, text/html |
HTML | q=0 is a rejection |
text/html,…,*/*;q=0.8 |
HTML | ordinary browser |
*/* |
HTML | a wildcard is not a preference |
*/* is deliberately not treated as competition either — every browser sends it
in the tail, and counting it would mean no client could ever request Markdown.
Response
Content-Type: text/markdown; charset=utf-8
Vary: Accept
X-Markdown-Tokens: 412
Link: <https://shop.example.com/products/zigarre>; rel="canonical"
Cache-Control: public, max-age=3600
ETag: "…"
Vary: Accept also goes on the HTML side of a negotiable route, via the
shared response-header listener. Without it a shared cache stores one variant
under a key that ignores Accept and serves it to clients asking for the other.
It is scoped to routes that actually have a provider — setting it globally
fragments the cache for every page that has no Markdown representation.
Custom representations
Implement MarkdownRepresentationProviderInterface from
koh/sylius-agent-contracts; container-wide autoconfiguration picks it up, so a
service in your own App\ namespace needs no wiring.
final class ArticleMarkdownProvider implements MarkdownRepresentationProviderInterface { public function supports(Request $request): bool { return 'app_shop_article_show' === $request->attributes->get('_route'); } public function render(Request $request, AgentContext $context): ?string { // return null to fall through to HTML } }
Returning null — or whitespace — falls through to the next provider and
ultimately to HTML. An empty Markdown document would look like a successful
answer and hide the gap.
Decommission
Remove the composer require, the config/bundles.php entry and
config/packages/koh_markdown.yaml. No database table, no route, no cache pool.
Purge any shared cache afterwards so stored Markdown variants expire.
Security
Report vulnerabilities privately — see SECURITY.md. Advisories are published as CSAF 2.0 at https://ole-hartwig.eu/.well-known/csaf/.
The family threat model, covering this package, is at https://github.com/ohartwig/sylius-agent-ready/blob/main/docs/threat-model.md.
Licence
MIT — see LICENSE.