inventor96 / inertia-offline
Framework-agnostic backend primitives for Inertia offline support.
Requires
- php: >=8.1
README
Framework-agnostic backend primitives for discovering which Inertia routes should be available offline.
Beta: offline read-only layer for Inertia.js apps, focused on safe cached content and navigation fallback.
This package is intentionally small: it provides the core algorithm and extension points, while each framework (Laravel, Mako, Symfony, Slim, etc.) supplies route inspection, action invocation, and pagination URL expansion.
This PHP backend package is designed to work with inertia-offline (the JS/TS frontend service worker library). Both the frontend and the backend aspects are required for an Inertia.js app.
Why This Exists
Offline-first Inertia apps usually need a server endpoint that returns a list of URLs to precache, often with TTL metadata. The hard part is generating that list in a reliable and maintainable way.
inventor96/inertia-offline solves this by:
- Marking cacheable actions with a PHP attribute.
- Scanning routes and reflecting on their actions.
- Expanding dynamic routes via a parameter generator.
- Optionally expanding pagination URLs.
- Returning normalized entries:
{ url, ttl }.
⚠️ This is (Probably) Not the Package You're Looking For
This package is not a drop-in solution for your framework. Instead, it's a core engine that you can build an adapter on top of for your specific framework. Below is a list of known adapters for popular frameworks.
inventor96/inertia-offline-mako(Mako framework)
Don't see yours? The core is designed to be adaptable to a wide range of frameworks with different routing and action paradigms. If you build an adapter for a framework, please consider opening a PR to add it to the list below!
Core Concepts
1. OfflineCacheable attribute
The #[OfflineCacheable(...)] attribute is used in individual projects on controller methods or functions that should be considered for offline caching.
<?php use inventor96\InertiaOffline\OfflineCacheable; final class PostController { #[OfflineCacheable(ttl: 3600)] public function index(): mixed { // ... } }
Attribute options:
ttl(int, default86400): route TTL in seconds.param_generator(callable|array|string|Closure|null): returns iterable parameter sets for dynamic route expansion.pagination_resolver(mixed|null): value or action that indicates pagination metadata.access_control(callable|array|string|Closure|null): action that decides if route should be included for current context/user.
2. AbstractOfflineRouteList
This abstract class contains the full generation pipeline (generateRoutes()):
- Iterate framework routes (
getRoutes()). - Resolve each route action (
getRouteAction()), normalize format, and build reflection. - Keep routes whose reflected action has
#[OfflineCacheable]. - Evaluate
access_controlwhen configured. - Build URL entries:
- Single URL from route pattern, or
- Multiple URLs from
param_generator.
- Expand paginated URLs when
pagination_resolveris configured. - Deduplicate and return
array{ array{url: string, ttl: int} }.
3. Action normalization and supported action types
The core supports common action formats:
- Function/closure/callable.
Class::methodstring.Class@methodstring.[ClassName, 'method']or[$instance, 'method'].
If reflection or invocation is not possible for an action, that route is skipped.
4. URL building strategy
Route URL creation is done from a route pattern and parameter set:
- Placeholder replacement:
/posts/{id}+['id' => 42]=>/posts/42 - Optional placeholders are removed when missing:
{slug?} - Extra params become query string values.
- If required placeholders are unresolved, the entry is skipped.
Contracts
inventor96\InertiaOffline\Contracts\OfflineRouteListInterfacegenerateRoutes(): array
inventor96\InertiaOffline\Contracts\PaginationUrlExpanderInterfaceexpand(string $baseUrl, mixed $pagination, mixed $route, OfflineCacheable $attribute, array $routeParams = []): array
Built-in Implementations
QueryPaginationUrlExpander
A ready-to-use implementation of PaginationUrlExpanderInterface that generates paginated URLs by appending a configurable query parameter (e.g. ?page=2) to the base URL.
<?php use inventor96\InertiaOffline\Pagination\QueryPaginationUrlExpander; $expander = new QueryPaginationUrlExpander(pageKey: 'page');
It resolves page count from the value returned by pagination_resolver in three ways:
| Resolver return type | Resolution strategy |
|---|---|
int |
Used directly as the page count. |
Object with numberOfPages() method |
Calls numberOfPages() and casts to int. |
Array with 'pages' key |
Reads $pagination['pages'] and casts to int. |
Given a base URL and a resolved page count n, it produces URLs for pages 1 through n.
Usage in an adapter
This expander is intentionally framework-agnostic, so it can serve as the default in any adapter. It's recommended that adapters accept an optional PaginationUrlExpanderInterface argument and fall back to QueryPaginationUrlExpander when none is provided, allowing projects to inject their own expander:
<?php use inventor96\InertiaOffline\AbstractOfflineRouteList; use inventor96\InertiaOffline\Contracts\PaginationUrlExpanderInterface; use inventor96\InertiaOffline\OfflineCacheable; use inventor96\InertiaOffline\Pagination\QueryPaginationUrlExpander; final class OfflineRoutes extends AbstractOfflineRouteList { public function __construct( // ... private readonly ?PaginationUrlExpanderInterface $paginationUrlExpander = null, ) {} protected function expandPaginationUrls( string $baseUrl, mixed $pagination, mixed $route, OfflineCacheable $attribute, array $routeParams = [], ): array { $expander = $this->paginationUrlExpander ?? new QueryPaginationUrlExpander(); return $expander->expand($baseUrl, $pagination, $route, $attribute, $routeParams); } // ... }
This pattern lets the adapter provide a sensible default while giving individual projects the flexibility to substitute a custom expander for pagination schemes that use path segments, cursor tokens, or other non-standard formats.
If your framework has a DI container, you can also consider making the expander a dependency of the adapter and letting projects bind their preferred implementation in the container.
Building a Framework Adapter
To implement this package for your framework, create a class extending AbstractOfflineRouteList and implement the abstract methods.
<?php namespace Acme\InertiaOfflineYourFramework; use inventor96\InertiaOffline\AbstractOfflineRouteList; use inventor96\InertiaOffline\OfflineCacheable; final class OfflineRoutes extends AbstractOfflineRouteList { protected function getRoutes(): iterable { // Return the entire set of routes from your framework's router. } protected function getRoutePattern(mixed $route): string { // Return route pattern, e.g. /posts/{id}. // `$route` is a single item from the iterable returned by `getRoutes()` } protected function getRouteAction(mixed $route): mixed { // Return route action in any supported format. // `$route` is a single item from the iterable returned by `getRoutes()` } protected function invokeAction(mixed $action, array $parameters = []): mixed { // Use your framework or DI container/invoker to resolve dependencies // and call an action. // `$action` is the normalized action from `getRouteAction()`. } protected function expandPaginationUrls( string $baseUrl, // from route pattern + params, e.g. /posts/42 mixed $pagination, // pagination metadata from `pagination_resolver` action mixed $route, // the original route item from `getRoutes()` OfflineCacheable $attribute, // the attribute instance from the reflected action array $routeParams = [], // the parameter set used to generate the base URL, e.g. ['id' => 42] ): array { // Convert pagination metadata into concrete URLs // e.g. /posts?page=1, /posts?page=2, ... } protected function logWarning(string $message): void { // Optional: send warnings to framework logger } }
Recommended Adapter Methodology
-
Route selection
- Return all routes from your framework's router in
getRoutes(). - Let the core filter down to cacheable routes via the presence of
#[OfflineCacheable]in individual projects.
- Return all routes from your framework's router in
-
Stable action invocation
- Ensure
invokeAction()can call class methods, closures, and container-resolved callables.
- Ensure
-
Pagination normalization
- Treat pagination resolver output as adapter-specific and convert to URLs via one expander implementation.
- Feel free to use
QueryPaginationUrlExpanderas the default insideexpandPaginationUrls()if it covers the common case for your framework. - Accept an optional
PaginationUrlExpanderInterfacein your adapter constructor so projects can supply a custom expander for non-standard pagination (path-segment pages, cursor tokens, etc.).
-
Observability
- Implement
logWarning()so unsupported actions or malformed generators are visible in logs.
- Implement
-
Deterministic output
- Keep route generation deterministic for the same user/context to improve cache behavior and debugging.
-
ETag support (optional)
- The frontend library supports the use of ETags and
If-None-Matchheaders for caching the route list and inertia requests. If your framework does not have built-in support for ETags, consider implementing a simple ETag middleware that projects can use.
- The frontend library supports the use of ETags and
Example: Dynamic Route + Pagination
<?php use inventor96\InertiaOffline\OfflineCacheable; final class PostController { #[OfflineCacheable( ttl: 1800, param_generator: [PostOfflineParams::class, 'recent'], pagination_resolver: [PostOfflinePagination::class, 'resolve'], access_control: [PostOfflineAccess::class, 'canUseOffline'] )] public function show(int $id): mixed { // ... } }
Possible flow:
- Route pattern:
/posts/{id} - Param generator returns:
[['id' => 10], ['id' => 11]] - Base URLs:
/posts/10,/posts/11 - Pagination resolver for each base URL returns 3 pages
- Final entries include:
/posts/10/posts/10?page=1/posts/10?page=2/posts/10?page=3/posts/11- etc.
(all with configured TTL of 1800 seconds)
Package Boundaries
This package does not:
- Register framework routes/controllers.
- Provide auth/session integration.
- Decide your endpoint payload format beyond route entry generation.
Those concerns belong in your framework adapter or host application.