Skip to content

Coming from Symfony

Symfony's Lock, RateLimiter, Cache, Scheduler, Messenger and security firewall all have a grelmicro equivalent. This page maps each one, says where the behavior differs, and lists what grelmicro leaves to other libraries.

Quick reference

Symfony grelmicro Page
Firewall with stateless: true and access_token AuthenticatedRequests, with exclude= for public paths Authentication
oidc token handler with audience, issuers, discovery JWTVerifier.discover(...) with audience= and issuer= JWT
access_control, #[IsGranted] Authenticated(scopes=[...]) Authentication
RateLimiter component RateLimiter.sliding_window(...), RateLimiter.token_bucket(...), RateLimitedRequests Rate limit
Lock component, createLock(), refresh() Lock("name"), extend() Lock
Cache Contracts get($key, fn), TagAwareCacheInterface @cached(cache, tags=[...]), cache.delete_tags(...) @cached
Cache stampede lock, beta early expiration lock= and early= on @cached Stampede protection
#[AsPeriodicTask], #[AsCronTask] @tasks.every(...), @tasks.cron("...") Task Scheduler
Scheduler ->lock(...) gate=LeaderElection(...) Task Scheduler
Scheduler ->stateful() with processOnlyLastMissedRun(true) A claimed cron task Missed fires
Messenger retries and failure_transport The outbox relay, its dead-letter state and outbox.redrive(...). @retry for a call in the same process Outbox, Retry
WebTestCase, static::createClient() Your framework's TestClient with micro.fake() Testing

Concept differences

The scheduler runs inside the service

Symfony runs schedules in a messenger:consume worker. grelmicro runs them in the service process, next to your routes. A process that serves no HTTP gets its probes from an OpsServer.

The schedule lock is a leader election

Symfony's ->lock() covers the whole schedule: one worker runs it and the others wait as standbys. That is gate=LeaderElection(...). gate="claim" is finer: each interval or fire goes to whichever worker claims it first.

The outbox is a table, not a bus

Messenger routes messages to transports such as Doctrine, AMQP or Redis. The grelmicro outbox is one PostgreSQL table, written inside your transaction and drained by a relay in the same service. For a broker, use FastStream.

Scopes, not roles

Authenticated(scopes=[...]) checks the scopes in the token. There are no roles and no voters. To check a role, read its claim with Claims in the handler and answer 403.

Locks are not reentrant

A second acquire of the same Lock from the same task raises LockReentrantError. Use two locks with different names when you need two independent locks.

Side by side

A job that runs on one worker

#[AsSchedule('default')]
class CleanupSchedule implements ScheduleProviderInterface
{
    public function getSchedule(): Schedule
    {
        return new Schedule()
            ->add(RecurringMessage::every('1 minute', new Cleanup()))
            ->lock($this->lockFactory->createLock('cleanup'));
    }
}
from grelmicro.coordination import LeaderElection
from grelmicro.providers.redis import RedisProvider
from grelmicro.task import Tasks

redis = RedisProvider("redis://localhost:6379/0")
leader = LeaderElection("my-service", backend=redis.leaderelection_backend())
tasks = Tasks()
tasks.add_task(leader)


@tasks.every(interval=60, gate=leader)
async def cleanup():
    print("Running cleanup...")

A protected API

security:
    firewalls:
        main:
            stateless: true
            access_token:
                token_handler:
                    oidc:
                        algorithms: ['RS256']
                        audience: 'orders-api'
                        issuers: ['https://auth.example.com/']
                        discovery:
                            base_uri: https://auth.example.com/
                            cache:
                                id: cache.app
    access_control:
        - { path: ^/catalog, roles: PUBLIC_ACCESS }
        - { path: ^/, roles: IS_AUTHENTICATED }
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)

A rate limit

framework:
    rate_limiter:
        api:
            policy: 'sliding_window'
            limit: 100
            interval: '1 minute'
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)

Work that must not be lost

framework:
    messenger:
        failure_transport: failed
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2
            failed: 'doctrine://default?queue_name=failed'
        routing:
            'App\Message\WelcomeEmail': async
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())

False friends

Word In Symfony In grelmicro
container The service container that injects dependencies The Grelmicro app that opens and closes your components, or a Docker container
component A reusable PHP library, such as Lock One pattern wired into the app, such as Cache or Coordination, with a start and a stop
provider A user provider that loads users A connection to one vendor, such as RedisProvider
profile The profiler's record of one request A Shield preset such as internal or api
tags Service tags. Cache tags mean the same in both Cache tags for bulk invalidation
middleware Messenger bus middleware ASGI middleware a component adds through micro.install(app)
channels Monolog channels such as app or doctrine Where a configuration warning shows up: as a Python warning and in the log

No equivalent, do this instead

  • Voters: check ownership in the handler, with CurrentPrincipal.
  • Console commands: use argparse, Typer or Click. python -m grelmicro check checks your wiring in CI.
  • The profiler: use request spans from Tracing and Metrics.
  • EventDispatcher: call the function. Use the Outbox for work after a commit.
  • Doctrine: use SQLAlchemy or SQLModel. Outbox.publish takes their AsyncSession.
  • Mercure: use your framework's WebSocket support with a broker. grelmicro still authenticates the handshake.
  • A fixed-window rate limiter: grelmicro has sliding window and token bucket.