RTL in Laravel and Tailwind: Arabic and Hebrew Without Hacks
The old way to support Arabic was a second stylesheet with every left flipped to right, loaded conditionally. It doubled your CSS, drifted out of sync with the main file, and broke whenever someone added a component without remembering the mirrored variant.
Tailwind 4 makes this mostly unnecessary. Logical properties handle direction at the CSS level, so one stylesheet serves both directions. But "mostly" is the operative word — a few things still need explicit handling, and knowing which ones saves you from discovering them via a screenshot from an Arabic-speaking user.
Start with the dir attribute
Everything downstream depends on the document declaring its direction:
{{-- resources/views/components/layouts/app.blade.php --}}
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
dir="{{ in_array(app()->getLocale(), ['ar', 'he', 'fa', 'ur']) ? 'rtl' : 'ltr' }}">
Note str_replace('_', '-', ...) on lang. HTML wants BCP 47 with hyphens (pt-BR), while Laravel locales use underscores (pt_BR). Getting this wrong is invisible to you and meaningful to screen readers and search engines.
Rather than repeating that array everywhere, put the direction in config next to your locale list:
// config/app.php
'available_locales' => ['en', 'es', 'de', 'ar', 'he'],
'rtl_locales' => ['ar', 'he', 'fa', 'ur'],
// app/Support/Locale.php
final class Locale
{
public static function isRtl(?string $locale = null): bool
{
return in_array($locale ?? app()->getLocale(), config('app.rtl_locales'), strict: true);
}
public static function dir(?string $locale = null): string
{
return self::isRtl($locale) ? 'rtl' : 'ltr';
}
}
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ \App\Support\Locale::dir() }}">
Logical properties do the heavy lifting
Once dir="rtl" is set, Tailwind's logical utilities flip automatically. The mapping you need:
| Physical (doesn't flip) | Logical (flips) |
|---|---|
| ml-4 / mr-4 | ms-4 / me-4 |
| pl-4 / pr-4 | ps-4 / pe-4 |
| left-0 / right-0 | start-0 / end-0 |
| text-left / text-right | text-start / text-end |
| rounded-l-lg / rounded-r-lg | rounded-s-lg / rounded-e-lg |
| border-l / border-r | border-s / border-e |
s is "start" and e is "end". In LTR, start means left; in RTL, start means right. So this works in both directions with no conditionals:
{{-- Icon sits before the label in both directions --}}
<button class="inline-flex items-center gap-2 ps-3 pe-4">
<x-icon.save class="size-4" />
{{ __('actions.save') }}
</button>
The practical migration is a find-and-replace with review. Most ml-/mr- in your codebase should become ms-/me-, and Flexbox and Grid already respect direction, so flex-row reverses on its own.
Worth knowing: space-x-* uses physical margins under the hood, so prefer gap-* in flex and grid containers. gap is direction-agnostic and it's the better utility anyway.
What doesn't flip — and shouldn't
Not everything should mirror. Getting this wrong produces interfaces that feel broken to native speakers in a different way.
Numbers and code stay LTR. Digits, phone numbers, IBANs, code snippets and URLs read left-to-right in every language:
<code dir="ltr" class="font-mono">php artisan translator:sync</code>
<span dir="ltr">+34 600 123 456</span>
Without dir="ltr", a phone number inside RTL text can render with the leading + in a position that looks wrong, because the bidirectional algorithm treats neutral characters according to surrounding context.
Media controls and progress follow content. A video scrubber conventionally runs left-to-right even in RTL locales. Timelines that represent chronology often stay LTR too. These are judgement calls — the test is what native users expect, not what's internally consistent.
Logos and brand marks don't mirror. Neither do icons depicting real objects with a fixed orientation.
Directional icons do flip. A "next" chevron should point left in RTL:
<x-icon.chevron-right class="size-4 rtl:rotate-180" />
Tailwind's rtl: variant is the escape hatch for exactly this, and it's the right tool when the flip is genuinely a visual decision rather than a layout one. Use it sparingly; if you're reaching for it a lot, you probably have physical properties that should be logical.
Text expansion is a layout problem too
While you're in here: translated text changes length, and RTL languages aren't the worst offenders — German is. A button sized to fit "Save" will not fit "Speichern", and a fixed-width sidebar built around English labels will wrap or clip.
The habits that help are the ones good CSS wants anyway: avoid fixed widths on anything containing text, let flex items grow, and test with your longest language rather than your shortest. If you only ever look at English, every layout looks fine.
Forms and inputs
Inputs inherit direction from the document, which is usually right. Two cases need attention.
Fields that hold LTR data inside an RTL page should be marked explicitly:
<input type="email" dir="ltr" class="w-full ps-3 text-start"
placeholder="{{ __('auth.email_placeholder') }}">
Email addresses, URLs and passwords are LTR content regardless of interface language.
And dir="auto" is useful for fields whose content language you don't know ahead of time — a comment box on a multilingual site:
<textarea dir="auto" name="comment"></textarea>
The browser infers direction from the first strong directional character the user types, so an Arabic comment renders RTL and an English one LTR in the same input.
Fonts
Latin webfonts don't include Arabic or Hebrew glyphs, so an unconfigured RTL page falls back to whatever the OS provides — usually legible, rarely attractive, and inconsistent across devices.
Scope a font stack per script:
@layer base {
:root:lang(ar),
:root:lang(he) {
--font-sans: 'Noto Sans Arabic', 'Noto Sans Hebrew', system-ui, sans-serif;
}
}
Two things worth knowing. Arabic fonts often need more vertical space than Latin ones at the same nominal size, so check line height on dense interfaces. And Arabic script is cursive — letters join and change shape by position — so it's genuinely worth having a designer or native speaker look at the result rather than assuming a font substitution is sufficient.
Things that break in ways you won't predict
A handful of RTL problems don't come from layout utilities at all, and they're the ones that survive a careful review.
Punctuation drifts. In RTL, a sentence-ending period renders at the left. When a string mixes Arabic with a Latin fragment — a product name, a URL — the bidirectional algorithm decides where neutral characters like ., :, ( and ) belong based on surrounding context, and it sometimes puts them somewhere that looks wrong. The fix is to isolate the embedded fragment:
{{-- The Latin fragment gets its own direction context --}}
<p>{!! __('billing.plan_notice', [
'plan' => '<span dir="ltr">'.e($plan->name).'</span>',
]) !!}</p>
Note e() on the interpolated value — you've opted into unescaped output for the wrapper, so the dynamic part still needs escaping. Alternatively use the Unicode isolate characters (U+2068 / U+2069) around the fragment, which avoids raw HTML entirely.
Shadows and gradients don't flip. shadow-lg has no directional variant, and an offset shadow that reads as depth in LTR reads as misalignment in RTL. Same for bg-gradient-to-r, which keeps pointing right. Use rtl:bg-gradient-to-l where the direction is meaningful, and prefer symmetric shadows.
Transforms and animations are physical. translate-x-4 moves right in both directions, so a slide-in drawer animates from the wrong side. Anything with a direction needs an rtl: variant:
<div class="translate-x-full rtl:-translate-x-full transition-transform">
Scroll position starts at the other end. Horizontally scrollable containers begin scrolled to the right in RTL. Browsers have historically disagreed on the sign of scrollLeft in RTL, so any JS reading or setting it needs testing rather than reasoning — this is a genuine cross-browser inconsistency, not something you can derive.
Charts and data visualisation. Axes, legends and bar direction are a judgement call. Numeric axes conventionally stay LTR; category labels follow the text direction. If you use a charting library, check whether it has an RTL mode before building workarounds.
Testing without speaking the language
You don't need Arabic to catch layout bugs. Force the direction and look:
it('renders the dashboard in rtl for arabic', function () {
$this->get('/dashboard?lang=ar')
->assertOk()
->assertSee('dir="rtl"', escape: false);
});
More useful is looking at the page. Set the locale to ar and check: does anything sit on the wrong side, does text overflow its container, do icons point the wrong way, do numbers render oddly. Layout problems are visible to anyone; only the copy itself needs a native speaker.
A trick worth knowing: pseudo-localization. Render your interface with strings padded and accented — [Ṡàṽé çḣàṅĝéṡ~~~] — and both untranslated strings and expansion breakage become obvious at a glance, in a build anyone on the team can review.
The part CSS can't fix
All of the above is layout. None of it translates a single word. An RTL-perfect Arabic interface still shows English text until lang/ar/*.php exists and is complete — and Arabic is the language where an incomplete file is most conspicuous, because Latin characters in the middle of Arabic text are visually unmistakable.
Arabic also has six plural forms against English's two, so trans_choice() lines need six segments; supply fewer and Laravel silently falls back to the first. The pluralization deep dive covers why, and the pluralization tester shows which numbers map to which form. Per-language notes for Arabic and Hebrew, including RTL specifics, are in the Laravel localization guides.
Checklist
- Set
diron<html>, andlangwith hyphens (pt-BR), not underscores. - Keep the RTL locale list in config, not inline in a Blade conditional.
- Replace
ml-/mr-/pl-/pr-/left-/right-/text-leftwithms-/me-/ps-/pe-/start-/end-/text-start. - Prefer
gap-*overspace-x-*, which uses physical margins. - Mark numbers, code, phone numbers and email inputs
dir="ltr"; usedir="auto"for user-generated content. - Flip directional icons with
rtl:rotate-180; don't flip logos or media controls. - Scope an Arabic/Hebrew font stack with
:lang(). - Test with your longest language, not your shortest.
Layout is a one-time investment. The translations behind it are the ongoing work — and for Arabic, six plural forms per countable string across every key. LangSyncer stores each language's forms with the key, fills gaps with AI, and publishes to production in seconds without a deploy.