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 answer403when 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:Bulkheadlimits concurrency in one process. Use aLockfor 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.publishtakes theirAsyncSession. - Artisan commands: use Typer or Click.
python -m grelmicro checkchecks your wiring in CI. - Broadcasting: use your framework's WebSocket support with a broker. grelmicro still authenticates the handshake.