Skip to content

I built a Python framework inspired by Laravel

Laravel has a way of making backend architecture feel effortless. Service providers wire dependencies, a container resolves them, and facades give you ergonomic access — with queues, events, cache and auth already integrated on a shelf. I borrowed those instincts, and spent a long time making them feel at home in Python.

This post is the honest accounting: what I copied, where Python asked for a different shape, and the rules that keep Laravel’s DX intact once everything is importable.

Laravel’s service provider was the first architecture that made dependencies feel solved, so I carried its DNA into QuadKit:

  • Service providers → providers with priorities. The register() / boot() lifecycle, ordered by ProviderPriority.
  • The container — container.singleton(Protocol, Impl) binds a shared instance; provider startup and reverse-order cleanup are handled by the separate lifecycle.
  • Contracts — Laravel’s contracts/interfaces inspired QuadKit’s shared boundary. quadkit-contracts has no QuadKit implementation imports; its only runtime dependency is typing-extensions.
  • Facades → ambient capabilities. clock.now(), identity.new_uuid(), hashing.hash_hex() keep Facades’ ergonomics — call it from anywhere — with the indirection made explicit: plain objects, injectable, overridable in tests.
  • Artisan → the CLI, with generators and contributors.
  • A shelf of layers, adopted à la carte. The published 0.0.50 set includes quadkit-events, quadkit-cache, quadkit-storage, quadkit-tasks, and quadkit-auth, among eighteen distributions — including quadkit-ai-llm, quadkit-ai-agents, quadkit-ai-mcp, quadkit-workflow, and quadkit-notification. Queue-specific code remains outside the published set; other workspace packages should not be mistaken for published distributions.

None of this is a dig at Laravel — the port is gratitude, not criticism. Both PHP and Python let applications import code across package boundaries; neither language automatically enforces an application’s architectural rules. QuadKit’s approach is to encode the rules that can be checked in types, lint rules, and CI, and document the trade-offs that remain. Three decisions show the pattern:

1. register() cannot resolve. Period. In Laravel a provider touches the container while registering — a freedom that works there. Ported to Python it quietly becomes ordering bugs, so QuadKit removes the freedom entirely by typing: register() receives ContainerRegistrarProtocol, boot() receives BootContainerProtocol — and the two never meet in the same hand.

class NotificationProvider(Provider):
async def register(self, container: ContainerRegistrarProtocol) -> None:
container.singleton(NotificationServiceProtocol, SlackNotifier)
async def boot(self, container: BootContainerProtocol) -> None:
notifier = await container.resolve(NotificationServiceProtocol)

Because ContainerRegistrarProtocol has no resolve() method, a static checker can flag a provider that tries to resolve during registration when that provider is included in the check. The API makes the registration/boot distinction explicit; Python reports a type-checking error, not a compile-time error.

2. Boundaries are law, enforced by a machine. Python’s import system allows imports that may compile yet couple packages too tightly. QuadKit’s working rule is: when packages share a protocol, type, or exception, prefer quadkit-contracts over a peer dependency. The root import-linter configuration checks regular-extension independence and separate AI/multimedia sub-package boundaries; it also excludes intentional composite packages and records specific tracked allowances. That scope matters: it is a set of explicit architectural checks, not a universal ban on every cross-package import. The contract-placement guide makes the choice reviewable instead of relying on taste alone.

3. Expected failures are values. Infrastructure failures raise. The most stealable idea in the codebase is a decision table with one column called “what the caller is expected to handle.” User not found is a return value; the database dying is an exception. Then the bans: no unwrap() without an is_ok() check, no Result from constructors or lifecycle hooks, no Any as the error type. A type that tells you which failures you must handle changes how the whole codebase reads.

All three of these rules turned out to matter for a second reason I did not expect: they are the rules a coding agent breaks first. Why I built QuadKit is the account of that — and the reason every law above is written as a type or a CI gate rather than as advice.

Security checks need explicit failure behavior

Section titled “Security checks need explicit failure behavior”

Security is not a feature-list claim; each check has concrete behavior. For example, quadkit-auth’s JWT authenticator rejects unverifiable tokens (and logs verification exceptions) rather than treating them as authenticated; see its security notes. The web layer’s middleware behavior is described separately in the quadkit-web security notes. App configuration and deployment still matter, so this should not be read as a blanket guarantee that every application is secure by default.

The punch worth stealing: an API that defaults to yes optimizes for the demo. Fail-closed optimizes for the 3 a.m. audit — and that’s the one that matters.

  • Registries never self-register. An empty constructor and a with_defaults() classmethod; dispatch through a registry, never if/elif chains.
  • Facades with honest overrides. The ambient clock, identity, and hashing are a documented exception to dependency injection — with a rationale, and a use() context manager so tests can swap them. The ergonomics of Facades, the overrides made explicit.
  • Tests validate the design. “No shims, no ad-hoc code to pass a test — fix the design, not the test.” In applications, put app-specific test doubles under tests/ and fake at contract boundaries; reusable framework fakes are provided by quadkit-testing.
  • No module-level singletons. Use container-managed lifetimes so shared instances remain explicit and testable.

The discipline is not a phase. The workspace contains additional integrations and an AI platform, but source code in the repository is not the same as a published distribution. The latest release, 0.0.50, has eighteen published packages. Package boundaries still apply across that development work, with the documented composite and tracked exceptions.

The rules are written for machines too: AGENTS.md, agent skills that teach Claude Code and opencode the same patterns, and docs that serve an llms.txt. The framework’s theory of itself is a first-class artifact.

If Laravel taught me what great developer experience feels like, Python taught me how to keep it. Write the rule down, encode what can be checked in types or CI, and document the rest so nobody has to rely on memory at 2 a.m.

  • If two codebases need the same type, make a home for shared contracts before the third one shows up — and write the rule down first.
  • Rules that live in READMEs rot. Rules enforced by an import linter, a type checker, or a CI gate are rules.
  • Facades are a good idea — the secret is making the convenience explicit. Export real objects, keep them swappable.
  • Being inspired is not something to hide. Frameworks are conversations with their teachers — and the best thing about Laravel is that it keeps agreeing to be copied.

QuadKit is open source.
Star it on GitHub, read the docs, or install it from PyPI.
If I got the laws wrong — or right — I’d love to hear from you.

I’m building QuadKit because I wanted Laravel’s developer experience with Python’s type safety. You can follow the journey on GitHub.

If you ship FastAPI today, you are not starting over — Coming from FastAPI is the story, and the migration guide is the concept map.