Skip to content

Coming from Laravel

grelmicro is not a web framework and has no ORM. It runs inside FastAPI, Starlette, Litestar or FastStream. What it gives you maps to Laravel's atomic locks, cache, rate limiter, onOneServer() scheduler and after-commit jobs. This page maps each one, says where the behavior differs, and lists what to use for the rest.

Quick reference

Laravel grelmicro Page
Schedule::call(...)->everyMinute(), ->cron(...) @tasks.every(interval=...), @tasks.cron("...") Task Scheduler
->onOneServer() gate="claim" on a task Task Scheduler
->withoutOverlapping() Nothing to add on one worker. Across workers, gate="claim" on an interval task Task Scheduler
Cache::lock('name', 10) Lock("name", lease_duration=10) Lock
Cache::remember(...) @cached(cache), cache.get_or_set(...) @cached
Cache::flexible(...) @cached(cache, early=0.1), a fraction of the TTL, not seconds Stampede protection
Cache::tags([...])->flush() cache.delete_tags(...) @cached
RateLimiter::for(...) and the throttle middleware RateLimitedRequests for the app, RateLimited for one route Rate limit
RateLimiter::attempt(...) limiter.acquire(key=...) Rate Limiter
->afterCommit() on a queued job Outbox.publish(...) inside the transaction Outbox
failed_jobs, queue:retry The outbox dead-letter state, outbox.redrive(...) Outbox
Passport auth:api with CheckToken::using(...) AuthenticatedRequests with a JWTVerifier, Authenticated(scopes=[...]) Authentication
Http::retry(3, 100) @retry(when=httpx.HTTPError, attempts=3) Retry
Log::withContext([...]) add_context() in a handler Tracing
The /up health route HealthChecks with /livez, /readyz and /healthz Health
HTTP tests, $this->get('/') Your framework's TestClient with micro.fake() Testing

Concept differences

The scheduler runs inside the service

Laravel runs schedule:run from a system cron entry every minute. grelmicro runs tasks in the service process, next to your routes, with no cron entry. A process that serves no HTTP gets its probes from an OpsServer.

A claimed task renews its claim

onOneServer() takes an atomic lock for each run. A claimed interval task renews its lease while the body runs, so a slow run does not overlap the next interval on another server. If the backend stays unreachable for a whole lease, the claim is lost and another worker may run. Add a Lock as sync for work that must never overlap. A claimed cron task records each fire it claims, and runs a fire missed while every worker was down, once.

A task never overlaps itself

In Laravel, a task runs even while the previous run is still going, unless you add withoutOverlapping(). A grelmicro task waits for its previous run on the same worker. On an interval task, gate="claim" covers the other workers too. A claimed cron task can still overlap a long run on another worker, so take a Lock inside its body when runs must never overlap.

Locks wait unless you say otherwise

Cache::lock(...)->get() returns false at once when the lock is taken. lock.acquire_nowait() raises WouldBlockError instead. async with lock: waits like block(), and lock.acquire(timeout=5) gives up after five seconds with LockTimeoutError. A grelmicro lock is not reentrant: a second acquire from the same task raises LockReentrantError.

Cache tags only drive invalidation

A Laravel tagged item can only be read with its tags. A grelmicro tag only marks the entry for delete_tags, and the key alone reads it.

Context lives on the call

Log::withContext([...]) adds fields to every later log line. grelmicro context follows the call across await. micro.install(app) opens one for each request and message, and @instrument and span() open one for a block, so add_context in a route handler adds its fields to every log record until the response. Outside all of them, add_context does nothing.

The outbox is a table in your transaction

->afterCommit() holds the dispatch until the transaction commits. Outbox.publish(...) writes the message in the same transaction as your rows, and a relay in the service delivers it at least once, with retries and a dead-letter state. The outbox needs PostgreSQL, through asyncpg or a SQLAlchemy AsyncSession.

Tokens come from an identity provider

Sanctum and Passport issue tokens. grelmicro only verifies them: it checks a JWT that your identity provider signed. A Sanctum token is an opaque value stored in your database, so JWTVerifier cannot check it. Moving to grelmicro means moving token issuing to an identity provider such as Keycloak, Auth0, Entra ID or Cognito. Passport scopes and Sanctum abilities map to Authenticated(scopes=[...]), which requires all of them.

Side by side

A job that runs on one server

use Illuminate\Support\Facades\Schedule;

Schedule::command('report:generate')
    ->everyFiveMinutes()
    ->onOneServer();
from grelmicro.task import Tasks

tasks = Tasks()


@tasks.cron("*/5 * * * *", gate="claim")
async def sync_data():
    print("Syncing on one worker")

A rate limit

RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
from fastapi import FastAPI

from grelmicro import Grelmicro
from grelmicro.http import ErrorResponses, RateLimitedRequests
from grelmicro.providers.redis import RedisProvider
from grelmicro.resilience import RateLimiter, RateLimiterComponent
from grelmicro.security import TrustedProxies

redis = RedisProvider("redis://localhost:6379/0")

burst = RateLimiter.sliding_window("burst", limit=100, window=60)
daily = RateLimiter.sliding_window("daily", limit=10000, window=86400)

micro = Grelmicro(
    uses=[
        RateLimiterComponent(redis),
        ErrorResponses(),
        RateLimitedRequests(
            burst,
            daily,
            trusted=TrustedProxies(["10.0.0.0/8"]),
            exclude=("/livez", "/readyz"),
        ),
    ]
)
app = FastAPI()

micro.install(app)

Pass key= to RateLimitedRequests to count per user instead of per client address.

Work after the commit

ProcessPodcast::dispatch($podcast)->afterCommit();
import asyncio
import os

from pydantic import BaseModel

from grelmicro import Grelmicro
from grelmicro.outbox import Message, Outbox
from grelmicro.providers.postgres import PostgresProvider

postgres = PostgresProvider(os.environ["POSTGRES_URL"])
outbox = Outbox(postgres)

micro = Grelmicro(uses=[outbox])
delivered = asyncio.Event()


class WelcomeEmail(BaseModel):
    to: str
    user_id: int


async def send_email(to: str, idempotency_key: str) -> None:
    print(f"welcome email to {to}")
    delivered.set()


@outbox.handler(WelcomeEmail)
async def send_welcome(message: Message[WelcomeEmail]) -> None:
    assert message.data is not None  # a typed handler always gets its model
    await send_email(to=message.data.to, idempotency_key=str(message.id))


async def sign_up(email: str) -> None:
    async with postgres.client.acquire() as conn, conn.transaction():
        user_id = await conn.fetchval(
            "INSERT INTO users (email) VALUES ($1) RETURNING id", email
        )
        await outbox.publish(conn, WelcomeEmail(to=email, user_id=user_id))


async def main() -> None:
    async with micro:
        await postgres.client.execute(
            "CREATE TABLE IF NOT EXISTS users (id serial PRIMARY KEY, email text)"
        )
        await sign_up("alice@example.com")
        async with asyncio.timeout(5):
            await delivered.wait()


if __name__ == "__main__":
    asyncio.run(main())

A protected API with scopes

use Laravel\Passport\Http\Middleware\CheckToken;

Route::get('/orders', function () {
    // Access token has both "orders:read" and "orders:create" scopes...
})->middleware(['auth:api', CheckToken::using('orders:read', 'orders:create')]);
from fastapi import FastAPI

from grelmicro import Grelmicro
from grelmicro.http import AuthenticatedRequests, ErrorResponses
from grelmicro.integrations.fastapi import (
    Anonymous,
    Authenticated,
    CurrentPrincipal,
)
from grelmicro.security import JWTVerifier

micro = Grelmicro(
    uses=[
        ErrorResponses(),
        AuthenticatedRequests(
            JWTVerifier.discover(
                "https://auth.example.com/", audience="orders-api"
            ),
            exclude=("/livez", "/readyz"),
        ),
    ]
)
app = FastAPI()


@app.get("/orders")
async def list_orders(principal: CurrentPrincipal) -> dict[str, str | None]:
    return {"caller": principal.subject}


@app.delete(
    "/orders/{order_id}",
    dependencies=[Authenticated(scopes=["orders:write"])],
)
async def cancel(order_id: int) -> dict[str, int]:
    return {"cancelled": order_id}


@app.get("/catalog", dependencies=[Anonymous()])
async def catalog() -> list[str]:
    return ["tea", "coffee"]


micro.install(app)

False friends

Word In Laravel In grelmicro
guard How users are authenticated, such as session or sanctum Not an authentication term. Authentication is AuthenticatedRequests
provider A service provider that boots the app, or a user provider A connection to one vendor, such as RedisProvider
facade A static interface to a service, such as Cache:: No such thing. You import and call the object
container The service container for dependency injection The Grelmicro app that opens and closes your components, or a Docker container
middleware groups The web and api groups No groups. A component adds its own ASGI middleware through micro.install(app)
tags Cache tags that scope reads and flushes Cache tags that only drive delete_tags
channels Log channels, or broadcasting channels Where a configuration warning shows up: as a Python warning and in the log
context The Context facade, carried into queued jobs The fields add_context adds for one request, message or span. The trace context follows an outbox message, other fields do not

No equivalent, do this instead

  • Issuing tokens with Sanctum or Passport: use an identity provider and JWTVerifier.discover(...).
  • Cookie sessions for a single-page app: keep them in your framework. grelmicro authenticates bearer tokens.
  • Gates and policies: check ownership in the handler, with CurrentPrincipal, and answer 403 when the caller may not act.
  • A job queue, Horizon and batches: use the Outbox for work after a commit, and Celery, Dramatiq, taskiq or FastStream for a queue.
  • Cache::funnel() across servers: Bulkhead limits concurrency in one process. Use a Lock for one at a time across replicas.
  • Pulse and Telescope: send Metrics to Prometheus and Tracing to any OpenTelemetry backend.
  • Eloquent: use SQLAlchemy or SQLModel. Outbox.publish takes their AsyncSession.
  • Artisan commands: use Typer or Click. python -m grelmicro check checks your wiring in CI.
  • Broadcasting: use your framework's WebSocket support with a broker. grelmicro still authenticates the handshake.