lorisleiva/cron-translator
Laravel/PHP package that turns CRON expressions into human-readable text, e.g. "30 22 * * *" becomes "Every day at 10:30pm." Supports 20 locales and optional 24-hour time formatting. Install via Composer: composer require lorisleiva/cron-translator.
Install it and you're done — this package has no service provider, no config file, and no publishable assets:
composer require lorisleiva/cron-translator
Sanity-check it from Tinker before wiring anything up:
use Lorisleiva\CronTranslator\CronTranslator;
CronTranslator::translate('*/2 * * * *'); // Every 2 minutes
CronTranslator::translate('0 16 * * 1'); // Every Monday at 4:00pm
CronTranslator::translate('30 18 * * *', 'fr', true); // Chaque jour à 18:30
Where to look first once installed, in this order:
src/CronTranslator.php — the entire public API is one static method: translate(string $expression, string $locale = 'en', bool $use24hours = false): string.src/CronExpression.php and src/Fields/ — the parser splits the expression into field objects (EveryMinute, EveryHour, EveryDay, EveryMonth, EveryYear, …). Reading these tells you exactly which shapes the package understands and which will blow up.src/Translations/ — one PHP class per locale. This is your source of truth for the supported locale list (ua for Ukrainian, zh-TW hyphenated, etc.) and the actual sentence templates.First real use case for a Laravel app: a scheduled-report (or webhook, or recurring-invoice) form where the user types a cron expression. Under the input, render a live preview: "Every day at 10:30pm". That single line removes 90% of the "what does this cron do?" support tickets, and it's the cheapest place to prove the value before you go wider (audit logs, API resources, CLI tooling).
The API is a static method on a concrete class, which means it's untestable in isolation, it throws on malformed input, and every call site would otherwise re-implement locale and 24-hour handling. Wrap it once:
namespace App\Support;
use Lorisleiva\CronTranslator\CronTranslator;
use Throwable;
class CronDescriber
{
private array $cache = [];
public function describe(?string $expression, ?string $locale = null, ?bool $use24h = null): ?string
{
if (blank($expression)) {
return null;
}
$locale = $this->normalizeLocale($locale ?? config('scheduling.locale') ?? app()->getLocale());
$use24h ??= (bool) config('scheduling.use_24h', true);
$key = "{$expression}|{$locale}|" . (int) $use24h;
return $this->cache[$key] ??= $this->translate($expression, $locale, $use24h);
}
private function translate(string $expression, string $locale, bool $use24h): string
{
foreach (array_unique([$locale, config('scheduling.fallback_locale', 'en')]) as $candidate) {
try {
return CronTranslator::translate($expression, $candidate, $use24h);
} catch (Throwable) {
// unsupported locale or unparseable expression — try the next one
}
}
return $expression; // never let a dashboard table 500 because of one bad row
}
private function normalizeLocale(string $locale): string
{
$locale = str_replace('_', '-', $locale); // en_US / zh_TW → en-US / zh-TW
return ['uk' => 'ua', 'zh-hant' => 'zh-TW', 'zh-hans' => 'zh'][strtolower($locale)] ?? $locale;
}
}
Bind it as a singleton and you get per-request memoization for free.
Give the column a human companion so controllers and resources stay dumb:
// app/Models/ScheduledReport.php
protected $casts = ['cron_expression' => 'string'];
protected function cronDescription(): Attribute
{
return Attribute::get(fn () => app(CronDescriber::class)->describe($this->cron_expression));
}
// app/Http/Resources/ScheduledReportResource.php
return [
'expression' => $this->cron_expression,
'expression_human' => $this->cron_description,
'timezone' => $this->timezone,
];
Always ship the raw expression alongside the translation. Power users want to copy the cron; the human string is a gloss, not a replacement.
This package translates; it does not validate. Use dragonmantank/cron-expression for the rule and the translator for the hint:
Validator::extend('cron', fn ($attribute, $value) => is_string($value) && Cron\CronExpression::isValidExpression($value));
$request->validate([
'cron_expression' => ['required', 'string', 'cron'],
'timezone' => ['required', 'timezone'],
]);
The translator's own exception is a poor error message ("Invalid CRON expression") — you want the validation layer to own the UX.
Route::get('/cron/preview', function (Request $request) {
return ['text' => app(CronDescriber::class)->describe($request->string('expression'))];
})->middleware(['throttle:60,1'])->name('cron.preview');
Debounce ~400ms on the client. Return the raw expression back on failure so the UI can show "not a valid schedule" rather than an empty box.
A genuinely useful internal tool — php artisan schedule:list shows expressions, this shows meaning:
use Illuminate\Console\Scheduling\Schedule;
class DescribeSchedule extends Command
{
protected $signature = 'schedule:describe';
public function handle(Schedule $schedule, CronDescriber $describer): int
{
$this->table(['Task', 'Expression', 'Human'], collect($schedule->events())->map(fn ($event) => [
$event->description ?: class_basename($event->command ?? 'Closure'),
$event->expression,
$describer->describe($event->expression),
])->all());
return self::SUCCESS;
}
}
Same trick works for auditing: listen to ScheduledTaskFinished / ScheduledTaskStarting and log $event->task->expression plus its translated form, so compliance exports read like prose instead of 0 2 * * 1-5.
Add the human string to your own JSON lang files and let the package do the scheduling vocabulary, not the sentence frame:
// resources/lang/fr.json
{ "Scheduled: :schedule": "Planifié : :schedule" }
{{ __('Scheduled: :schedule', ['schedule' => $report->cron_description]) }}
This keeps your product's tone of voice in your own repo while the cron parsing stays in the package.
{{-- components/cron-badge.blade.php --}}
<span class="font-mono text-xs text-gray-500" title="{{ $human ?? $expression }}">{{ $expression }}</span>
@if($human)
<span class="text-sm text-gray-700">{{ $human }}</span>
@endif
Hover the raw cron, read the human string — best of both, and the column stays narrow.
app()->getLocale() returns en_US, zh_TW, uk; the package wants en, zh-TW, ua. Ukrainian is ua, not uk — this silently catches everyone. Normalize and map (see the normalizeLocale helper above) or you'll get English output for Ukrainian users and not notice.'xx' and assert your wrapper falls back rather than throwing — that contract is yours, and you don't want it to depend on upstream changes.vendor:publish. Translations are PHP classes, not lang files, so you cannot edit the copy for a single locale without forking or PR-ing upstream. If your product has a house style ("Each Monday" vs "Every Monday"), accept the package's phrasing or budget for a fork.L, W, #, ?, @daily aliases — are not part of the supported grammar. Real-world expressions pasted from other systems (Kubernetes, Quartz, Jenkins @hourly) will throw. Normalize aliases to 5-field cron before calling translate(), and treat a thrown exception as "display raw expression".0 0 1 * 1 fires on the 1st of the month and every Monday (either-or, actually). Any English rendering will mislead. If your users can produce such expressions, restrict the form to one of the two fields or add a caveat.0 and often 7. Verify how your chosen expression maps before you ship weekday copy; a wrong weekday label in a billing schedule is a bad bug.$use24hours defaults to false, so 30 18 * * * renders "6:30pm". For EU/LatAm/APAC audiences that reads as a bug. Set your default to true in config and let it be an explicit opt-out.* 1-20 * * * → "Every minute 20 hours a day"; 1,2 0 */2 1,2 * → "Twice an hour every 2 days 2 months a year at 12am". The simple cases (every N minutes, daily at X, every weekday) are excellent; the exotic ones are syntactically correct but not something you'd put in a marketing screenshot. Test with the actual expressions your users create — a small set of "supported shapes" in your form (frequency picker) beats a free-text cron field here.0 9 * * * is "Every day at 9:00am" in whatever timezone the scheduler runs. Always render the timezone next to the description, and double-check whether your jobs are stored in UTC while the user thinks in local time — the translation is technically correct and practically wrong.translate() three times per row for the same handful of expressions is pointless work. The $cache array in CronDescriber covers 99% of it; use Cache::remember keyed on expression+locale+format if you're rendering across requests.0 0 30 2 * (February 30th) translates happily. Semantic validity is on you.^0.5 and add a Pest dataset that asserts one translation per locale you actually ship.CronDescriber wrapper is the extension point — put locale mapping, 24-hour defaulting, fallback, caching, and try/catch there and nowhere else, so a future swap (or a PR to upstream) touches one file."Every day at 10:30pm" is upstream's wording, not your contract). Assert your wrapper delegates correctly, and keep one smoke test per locale. Otherwise a translation tweak in a patch release breaks your CI for no product reason.How can I help you explore Laravel packages today?