DEV Community

chatii
chatii

Posted on

Ask Your Eloquent Model "Is It Due?": Yarunoka's Laravel Bridge Is Out

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

The schedule column holds plain JSON:

{
  "label": "Recurring process",
  "description": "Runs at 10:00 on business days.",
  "days": ["business_day"],
  "times": ["10:00"]
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

"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' => [],
];
Enter fullscreen mode Exit fullscreen mode

With this in place, a stored schedule only needs the rule:

{
  "days": [25],
  "shift": ["prev", "or_same", "business_day"],
  "times": ["10:00"]
}
Enter fullscreen mode Exit fullscreen mode

"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,
],
Enter fullscreen mode Exit fullscreen mode

Resolvers are built by Laravel's service container, so ordinary dependency injection works:

class CompanyHolidayResolver implements YrnkResolverInterface
{
    public function __construct(
        private CompanyHolidayRepository $repository,
    ) {
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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',
],
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

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(),
    ],
]);
Enter fullscreen mode Exit fullscreen mode

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"] }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The service provider is auto-discovered. To customize the config:

php artisan vendor:publish --tag=yarunoka-config
Enter fullscreen mode Exit fullscreen mode

A TypeScript implementation and more are still on the roadmap. Feedback is very welcome.

Top comments (0)