meniam/typograph-bundle
Symfony bundle that adds a Twig filter for typographing text using E. Muravjev's Typograph. Install via Composer, register the bundle, then apply {{ content | typograph }} in templates. Supports multiple named configs so different content types, like articles or news, can use their own typography se...
This is a Symfony bundle, not a Laravel package. There is no composer.json extra.laravel block, no service provider, no facade, no config publish tag. composer require meniam/typograph-bundle in a Laravel app installs the code and does nothing else. Your job is to read the bundle's source and re-wire the one class that matters into Laravel's container.
Step 1 — install and look at what you got.
composer require meniam/typograph-bundle
find vendor/meniam/typograph-bundle -maxdepth 3 -type f -name '*.php' | sort
Read these three things, in this order:
src/*Extension.php (the Twig extension behind {{ content | typograph }}). This is the reference implementation — it tells you the exact service/class that does the work, its constructor arguments, and how the named config is selected.DependencyInjection/Configuration.php. This is the only authoritative schema for the typograph.configs.* YAML tree shown in the README. The README truncates mid-sentence at "The...", so do not trust it.Resources/config/services.yml (or .xml). This shows the service definitions and arguments you need to reproduce by hand.Step 2 — translate the config tree into config/typograph.php.
// config/typograph.php
return [
'default' => [
'Text.paragraphs' => 'on',
'OptAlign.oa_oquote' => 'off',
'OptAlign.oa_obracket_coma'=> 'off',
],
'articles' => [
'Text.paragraphs' => 'on',
],
'news' => [
'Text.paragraphs' => 'off',
],
];
Keep the keys verbatim from Configuration.php — they are Muravjev's rule identifiers (Text.*, OptAlign.*, Dash.*, Space.*, Quote.*), not Laravel-style snake_case, and the underlying processor expects them untouched.
Step 3 — a singleton service. Confirm the real FQCN in vendor/ rather than copying this blindly; the wrapper class name differs between the bundle and the underlying meniam/typograph library.
namespace App\Services;
use TypographBundle\Typograph; // VERIFY in vendor/ before trusting this
class TypographService
{
/** @var array<string, array<string, string>> */
private array $configs;
public function __construct(array $configs)
{
$this->configs = $configs;
}
public function format(?string $text, string $profile = 'default'): string
{
if ($text === null || $text === '') {
return '';
}
$config = $this->configs[$profile] ?? $this->configs['default'];
return (new Typograph($config))->process($text);
}
}
// app/Providers/TypographServiceProvider.php
public function register(): void
{
$this->app->singleton(TypographService::class, fn ($app) =>
new TypographService($app['config']->get('typograph', []))
);
}
Register it in bootstrap/providers.php (Laravel 11+) or config/app.php providers (10 and below).
Step 4 — your first real use case. Almost always this: you have editor-authored or user-submitted Russian text going into a Blade view, and you want «ёлочки» instead of "", an em dash instead of a hyphen, a non-breaking space before г. / № / prepositions, and correct ellipses.
// app/helpers.php — autoload via composer.json "autoload": { "files": ["app/helpers.php"] }
function typograph(?string $text, string $profile = 'default'): string
{
return app(\App\Services\TypographService::class)->format($text, $profile);
}
<h1>{{ typograph($post->title) }}</h1>
<div class="prose">{!! typograph($post->body, 'articles') !!}</div>
Note the escaping asymmetry: use {{ }} for headline/inline text, {!! !!} only for bodies that are already trusted HTML — and see the HTML/plain-text rule in Gotchas before you do that.
Pattern 1 — Helper function as the primary ergonomic layer. Blade has no pipe filters, so the Twig | typograph example from the README cannot be translated literally. A global helper is the closest equivalent and the one you will actually type all day. Register through composer.json "files" autoload, composer dump-autoload, done.
Pattern 2 — Keep raw and formatted side by side. Typography is presentation, not storage. Do not mutate body on save. Either format at read time (fine for detail pages, expensive for list pages) or add an indexed body_typographed column filled by a job.
// read-time accessor, good for single-record views
public function getBodyFormattedAttribute(): string
{
return app(TypographService::class)->format($this->body, 'articles');
}
For a list of 200 items this becomes 200 regex passes per request. Move to a column + queue once you exceed a handful of records.
Pattern 3 — Queued bulk backfill. The standard retrofit when you adopt typography after content already exists.
class TypographArticle implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function __construct(public int $articleId) {}
public function handle(TypographService $typograph): void
{
$article = Article::findOrFail($this->articleId);
if ($article->body_typographed !== null) {
return; // idempotent — safe to re-run
}
$article->update(['body_typographed' => $typograph->format($article->body, 'articles')]);
}
}
Article::query()
->whereNull('body_typographed')
->chunkById(500, fn ($rows) => $rows->each(
fn ($row) => TypographArticle::dispatch($row->id)
));
Make the job idempotent and the config profile explicit — you will re-run this after tuning rules.
Pattern 4 — Cache the formatted result, keyed by content hash. Typographing is regex-heavy; a 4 ms typographer on 20 list items is 80 ms you did not budget.
public function formattedBody(Article $article): string
{
return Cache::remember(
"article:{$article->id}:typographed:{$article->updated_at?->timestamp}",
now()->addDay(),
fn () => app(TypographService::class)->format($article->body, 'articles')
);
}
Including updated_at (or a content hash) in the key means you never need explicit busting on saved.
Pattern 5 — Named profiles per content type. The bundle's best idea: default, articles, news, and a titles profile where you disable Text.paragraphs because wrapping a heading in <p> is nonsense.
'anchor' => ['Text.paragraphs' => 'off'], // single-line contexts
'articles'=> ['Text.paragraphs' => 'on', 'OptAlign.oa_oquote' => 'on'],
'email' => ['Text.paragraphs' => 'off', 'Space.nb_nbsp' => 'on'],
Expose the profile name as a parameter on the helper rather than duplicating services.
Pattern 6 — Form request normalization, into a separate field. If typography must be stored, do it in passedValidation() and write to a different column. Overwriting body in prepareForValidation() destroys the author's straight quotes and dashes permanently — the author then cannot type a literal -- in a code sample ever again.
protected function passedValidation(): void
{
$this->merge([
'body_typographed' => app(TypographService::class)->format($this->input('body'), 'articles'),
]);
}
Pattern 7 — Resource layer, not middleware. Tempting to write a TypographResponse middleware that walks the JSON and formats every string leaf. Don't — it will happily rewrite slugs, emails, URLs, ISO dates, and hex colour codes. Format in the ArticleResource where you know which keys are prose.
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'slug' => $this->slug, // never typographed
'title' => typograph($this->title, 'anchor'),
'body' => typograph($this->body, 'articles'),
];
}
Pattern 8 — Facade for IDE ergonomics. Typograph::format($text) reads better than app(...) inside services. Add @method static string format(?string $text, string $profile = 'default') on the class docblock so static analysis is satisfied.
Pattern 9 — Respect the Markdown/HTML boundary. Typograph operates on plain text. Run it before Markdown conversion, or only on the text nodes of a rich-text document. Running it over stored HTML rewrites class="..." quotes into «», mangles alt attributes, and destroys code blocks. If the editor outputs HTML, either (a) typograph the plain-text source and re-render, or (b) walk the DOM with DOMDocument, format only text nodes, and serialise back.
new TypographBundle\FenrizbesTypographBundle() will fatal in Laravel. There is no AppKernel.php, no bundle kernel, and Symfony's DI compiler pass will not run. Do not attempt to register the bundle — extract the worker class and write your own provider. Budget half a day of reading vendor/.DependencyInjection/Configuration.php is the only source of truth for rule keys and defaults. If a key you guessed is silently ignored, that is why.it-blaster/typograph-bundle renamed to meniam. Pin the exact version in composer.json, commit composer.lock, and plan to vendor a fork. Strongly consider depending on the underlying meniam/typograph library directly and skipping the Symfony bundle wrapper entirely — you are not using any of its Symfony integration anyway.twig/twig lands in your composer.json as a transitive dependency for a feature Blade will never use. Harmless, but it will show up in dependency audits and will confuse the next developer.public function test_typographer_actually_runs(): void
{
$this->assertSame(
'«Привет» — мир',
app(TypographService::class)->format('"Привет" - мир')
);
}
If that test passes with the input string, your config keys are wrong or your charset is misconfigured.
default_charset, and the typographer's configured charset all have to agree. When they don't, you get mojibake that looks like a typography bug and sends you hunting in the rule config for hours.-->, -->, straight quotes, and ... inside code are correct and the typographer will "fix" them into corruption. Maintain an allowlist of prose fields — never a denylist.| filters. Every README example ({{ content | typograph }}, {{ article.content | typograph('articles') }}) is a syntax error in Blade, not a typography bug. Rewrite as typograph($content) or typograph($article->content, 'articles').Cache::remember on the formatted output is non-optional at any real scale.dataProvider of before/after pairs committed to the repo is the cheapest insurance you have.How can I help you explore Laravel packages today?