In my previous post, I introduced Yarunoka, a language-independent JSON DSL for calendar-aware schedules:
https://dev.to/chatii/schedules-are-rules-not-lists-of-timestamps-introducing-yarunoka-98i
The core idea was: schedules are rules, not lists of timestamps. A rule like "the 25th of every month, moved to the previous business day if needed" should live in the data itself, not be scattered across conditionals in application code.
At the end of that post, I listed a Laravel bridge as one of the next steps. It is now released.
composer require yarunoka/laravel
- Packagist: https://packagist.org/packages/yarunoka/laravel
- Documentation: https://yarunoka.dev/docs/php/laravel/1.0/
Yarunoka itself actually started inside a personal Laravel application—I extracted the specification and the evaluation engine first, and this bridge brings it back to where it came from.
A schedule as a model attribute
yarunoka/core gives you a parser, a validator, and an evaluator. But in a Laravel application, I do not want to assemble those by hand every time. If I have a Routine model that represents something recurring, I want this:
$routine->schedule
The bridge provides an Eloquent cast that stores a Yrnk Schedule directly in a JSON column:
use Yarunoka\Laravel\Schedule;
class Routine extends Model
{
protected function casts(): array
{
return [
'schedule' => Schedule::class,
];
}
}
The schedule column holds plain JSON:
{
"label": "Recurring process",
"description": "Runs at 10:00 on business days.",
"days": ["business_day"],
"times": ["10:00"]
}
And reading the attribute gives you a Schedule object back. The rule stays as data in the database, readable by humans, editable through a UI, and usable by the application—without turning into code.
Asking "is it due?"
Once the schedule is a model attribute, you can ask the question the whole project is named after (yaru no ka? — "so, do we do it?"):
if ($routine->schedule->isDue(
now(),
since: $routine->last_run_at,
)) {
// do it
}
Note the since argument. A periodic checker rarely wakes up at exactly 10:00:00. If the previous check was at 09:55 and the current one is at 10:05, the 10:00 occurrence happened in between.
So isDue() does not ask "is now the scheduled time?" It asks "was there an occurrence between the last check and now?" That is the question a real application needs answered.
Why core has no isDue()
This method only exists in the bridge, and that is intentional.
As I wrote in the previous post, Yarunoka is not a job scheduler. It does not execute anything, and it knows nothing about job state or last successful runs. The core evaluator only offers a pure query:
$evaluator->hasMatchIn($schedule, $since, $at);
"Did this interval contain an occurrence?" What happens after true is the application's business—run a job, send a notification, or do nothing.
isDue() is a thin, application-flavored word on top of hasMatchIn(). Convenient vocabulary belongs in the bridge; the core stays a pure query engine.
The calendar lives in your environment
A Yrnk Schedule can say "days": ["business_day"], but what counts as a business day is a separate concern. In most Laravel applications, that definition is shared across the whole app, not stored per record. So the bridge lets you put the Calendar in config/yarunoka.php:
return [
'timezone' => 'Asia/Tokyo',
'calendar' => [
'holidays' => 'yasumi-Japan',
'business_holidays' => [
'2026-08-14',
],
'business_days' => [],
],
'resolvers' => [],
];
With this in place, a stored schedule only needs the rule:
{
"days": [25],
"shift": ["prev", "or_same", "business_day"],
"times": ["10:00"]
}
"What is a business day?" is answered by the environment. If you have Yasumi installed, 'yasumi-Japan' supplies Japanese public holidays.
Date lists can be names
Look at the config again: business_holidays holds a list of dates, but holidays holds a string. Every date-list position in the Calendar accepts either the dates themselves or the name of something that resolves them. yasumi-Japan is one such name.
You can add your own names with Resolvers. This is for date sets that cannot be fixed values:
- company holidays managed in the database;
- extra business days editable from an admin panel;
- holiday data fetched from an external API.
'resolvers' => [
'company-holidays' => CompanyHolidayResolver::class,
],
Resolvers are built by Laravel's service container, so ordinary dependency injection works:
class CompanyHolidayResolver implements YrnkResolverInterface
{
public function __construct(
private CompanyHolidayRepository $repository,
) {
}
// ...
}
Now company-holidays is usable anywhere the DSL accepts a name—in the config Calendar, or inside schedules stored in the database:
'calendar' => [
'business_holidays' => 'company-holidays',
],
From the core's point of view, this is just "resolve the date set named company-holidays". Whether that means a database query or an API call is the bridge's problem, not the core's.
Or bind a layer directly
There is a second route. Each Calendar layer—holidays, business holidays, extra business days—can be supplied straight from the container, bypassing the config:
use Yarunoka\Resolvers\YrnkBusinessHolidaysResolverInterface;
$this->app->bind(
YrnkBusinessHolidaysResolverInterface::class,
DatabaseBusinessHolidaysResolver::class,
);
A binding takes precedence over the same layer in the config. If the source of your business holidays already exists as an application service, binding it directly is more natural than registering a name in the config. The config describes the environment; the container supplies the implementations.
Validation is real validation
Since schedules are stored as JSON, invalid schedules must not reach the database. The bridge ships a validation rule:
use Yarunoka\Laravel\Rules\ValidYrnkSchedule;
$validated = $request->validate([
'schedule' => [
'required',
new ValidYrnkSchedule(),
],
]);
This is not a simplified lookalike validator. It loads the input as actual Yarunoka and checks the structure, the values, the Calendar vocabulary, and whether every Resolver name can be resolved. The Eloquent cast validates on save as well, so even a direct assignment cannot sneak a broken schedule into the database.
One Schedule, or Schedules
A small design story. A Yrnk document can hold multiple schedules:
{
"schedules": [
{ "days": ["mon"], "times": ["10:00"] },
{ "days": ["fri"], "times": ["18:00"] }
]
}
Pulled along by that structure, my first version of the Laravel Schedule cast held this whole schedules array. So $routine->schedule—singular—contained multiple schedules inside. I caught it right before release.
What an Eloquent attribute named schedule should hold is, obviously, one schedule. The fact that a Yrnk document can hold many is a separate question from what one model attribute holds. The final design:
'schedule' => Schedule::class, // one schedule
'schedules' => Schedules::class, // a combination of schedules
There is also a cast for a whole Yrnk Document, for cases where each record should carry its own timezone and Calendar. One column can store a Schedule, a Schedules, or a full Document—whichever unit fits your use case.
Why the bridge is separate
Yarunoka started inside a Laravel application, so I could have released it as a Laravel-only library from day one. But the schedule rules themselves and "how to use them in Laravel" are different things.
yarunoka/core reads Yrnk, writes it, validates it, and answers occurrence queries. yarunoka/laravel adds the config, the container integration, the Eloquent casts, the validation rule, and isDue().
The previous post said Yarunoka is indifferent to execution. In the same way, the core is indifferent to Laravel. You get to use it in a Laravel-native way, without the DSL itself becoming Laravel-specific.
Try it
composer require yarunoka/laravel
The service provider is auto-discovered. To customize the config:
php artisan vendor:publish --tag=yarunoka-config
- Documentation: https://yarunoka.dev/docs/php/laravel/1.0/
- Packagist: https://packagist.org/packages/yarunoka/laravel
- GitHub: https://github.com/yarunoka-dev
- Website: https://yarunoka.dev/
A TypeScript implementation and more are still on the roadmap. Feedback is very welcome.
Top comments (0)