A Laravel-inspired framework for Python — built on top of Flask + SQLAlchemy. Elegant. Expressive. Modern.
Laraflask brings Laravel's developer experience philosophy to the Python ecosystem: an Eloquent-style ORM, an Artisan CLI, a Service Container with dependency injection, Blade-like templating, a Job Queue, a Task Scheduler, and more than 20 other ready-to-use modules — all with an API that feels familiar to anyone who has ever written Laravel code.
# Feels like Laravel, written in Python
class PostController(Controller):
def index(self):
posts = Post.where('published', True).order_by_desc('created_at').paginate(15)
return PostResource.collection(posts)
def store(self):
data = StorePostRequest().validate()
post = Post.create(data)
Events.dispatch(PostCreated(post))
return PostResource(post).to_response()- Installation
- Creating a New Project
- Directory Structure
- Core Concepts
- Routing
- Controllers
- ORM (EloquentPy)
- Validation (Validator & FormRequest)
- Authentication (Auth)
- Authorization (Gate & Policy)
- Middleware
- Security (CSRF, XSS, Encryption)
- Cache
- Events & Listeners
- Queue & Jobs
- Task Scheduler
- Notifications
- Storage / Filesystem
- Template Engine (BladePy)
- API Resource & JSON:API
- WebSocket & Broadcasting
- Testing
- Artisan CLI
- Tinker (Interactive REPL)
- Advanced Container (Contextual Binding & Tagging)
- Macroable
- Exception Handling
- Deployment
- Changelog
- Known Limitations
- License
- Contributing
- Python ≥ 3.10
- pip
- (Optional) MySQL / PostgreSQL / Redis for production
git clone https://github.com/Laraflask-Community/laraflask.git
cd laraflask# Minimal — Flask + SQLAlchemy only
pip install laraflask-core
# With all optional features
pip install laraflask-core[all]Laraflask uses extras to keep dependencies lightweight — install only what you need:
| Extra | Command | Purpose |
|---|---|---|
mysql |
pip install laraflask-core[mysql] |
MySQL driver (PyMySQL) |
postgresql |
pip install laraflask-core[postgresql] |
PostgreSQL driver (psycopg2) |
vector |
pip install laraflask-core[vector] |
pgvector for semantic/similarity search |
redis |
pip install laraflask-core[redis] |
Redis for cache & queue |
auth |
pip install laraflask-core[auth] |
bcrypt + PyJWT + cryptography |
queue |
pip install laraflask-core[queue] |
Celery + Kombu |
storage |
pip install laraflask-core[storage] |
Amazon S3 (boto3) |
notifications |
pip install laraflask-core[notifications] |
Twilio (SMS/WhatsApp) |
websocket |
pip install laraflask-core[websocket] |
Flask-SocketIO |
testing |
pip install laraflask-core[testing] |
pytest + factory-boy + faker |
dev |
pip install laraflask-core[dev] |
Full development toolchain |
production |
pip install laraflask-core[production] |
Gunicorn + Gevent |
💡 Some features (bcrypt, PyJWT, cryptography) use graceful degradation — if not installed, Laraflask automatically falls back to a simpler implementation (fine for development, not recommended for production).
# Copy the environment file
cp .env.example .env
# Generate APP_KEY
python artisan.py key:generate
# Run migrations
python artisan.py migrate
# (Optional) seed initial data
python artisan.py db:seed
# Start the development server
python server.py
# or
python artisan.py serveThe application will run at http://127.0.0.1:8000.
APP_NAME=Laraflask
APP_ENV=local
APP_KEY=base64:result_of_key_generate
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_TIMEZONE=UTC
APP_LOCALE=en
# Database
DB_CONNECTION=sqlite
DB_DATABASE=database/laraflask.db
# Cache
CACHE_DRIVER=file
# Session
SESSION_DRIVER=filesystem
# Queue
QUEUE_CONNECTION=syncSee .env.example for the full list of variables (Redis, Mail, AWS S3, JWT, Telegram, Twilio, Firebase).
laraflask/
├── core/ — Application, Container, Config, Exceptions, Providers,
│ Collection, Macroable, Model decorators (@table, etc.)
├── routing/ — Router, Route, RouteGroup
├── orm/ — Model (EloquentPy), DB, QueryBuilder, Migration, Schema
├── auth/ — Auth, JWT, Hash, Gate, Policy, Guards
├── cache/ — Cache (File, Redis, Database, Array drivers)
├── events/ — EventDispatcher, Event, Listener, Subscriber
├── queue/ — Queue, Job, Interruptible, Worker
├── scheduler/ — Schedule, ScheduledEvent
├── security/ — CsrfToken, PreventRequestForgery, XSS, Crypt
├── storage/ — Storage (Local, S3 drivers)
├── notifications/ — Notification, Mail, SMS, Telegram, WhatsApp, Push
├── middleware/ — Middleware base + built-in middleware
├── api/ — ApiResponse, ApiResource, JsonApiResource, RateLimiter
├── validation/ — Validator, FormRequest
├── template/ — BladePy template engine
├── testing/ — TestCase, FeatureTestCase, Fakes
├── ws/ — WebSocket & SSE (Server-Sent Events)
└── console/ — Artisan CLI commands
my-app/
├── app/
│ ├── Console/ — Custom Artisan commands
│ ├── Controllers/ — Controllers
│ ├── Events/ — Application events
│ ├── Exceptions/
│ │ └── Handler.py — Global exception handler
│ ├── Jobs/ — Queue jobs
│ ├── Listeners/ — Event listeners
│ ├── Middleware/ — Application middleware
│ ├── Models/ — Eloquent-style models
│ ├── Notifications/ — Notification classes
│ ├── Policies/ — Authorization policies
│ ├── Providers/ — Application service providers
│ ├── Requests/ — FormRequests
│ ├── Services/ — Service / business-logic classes
│ └── Traits/ — Reusable mixins/traits
├── config/ — Configuration files (app, database, cache, etc.)
├── database/
│ ├── factories/ — Model factories (for testing/seeding)
│ ├── migrations/ — Migrations
│ └── seeders/ — Database seeders
├── resources/
│ └── views/ — BladePy templates (.blade.html)
├── routes/
│ ├── web.py — Web routes (session-based)
│ ├── api.py — API routes (stateless)
│ └── console.py — Custom Artisan command routes
├── storage/
│ ├── app/ — Application-uploaded files
│ ├── framework/ — Cache, session, compiled views
│ └── logs/
├── public/ — Public assets (web server entry point)
├── tests/
│ ├── Unit/ — Unit tests
│ └── Feature/ — Feature/integration tests
├── .env — Active environment (never commit this)
├── .env.example
├── artisan.py — Artisan CLI entry point
├── server.py — Server entry point (`python server.py`)
└── conftest.py
Application is the heart of the framework — it extends Container (the IoC container), so every dependency-injection feature of the container is automatically available at the application level.
from laraflask.core.application import Application
app = Application(base_path='/path/to/my-app')
app.bootstrap() # boots every service provider
flask_app = app.get_flask() # grab the underlying Flask instancePath Helpers (following Laravel's app_path(), base_path(), etc. pattern):
app.path('public') # base_path/public
app.app_path('Models') # base_path/app/Models
app.config_path() # base_path/config
app.database_path() # base_path/database
app.resource_path('views') # base_path/resources/views
app.storage_path() # base_path/storage
app.public_path() # base_path/public
app.routes_path() # base_path/routesEnvironment checks:
app.environment() # 'local' | 'production' | 'testing'
app.environment('local', 'staging') # True if the environment matches either one
app.is_production()
app.is_local()
app.is_testing()The Laraflask container supports regular binding, singletons, instances, contextual binding, and tagging.
from laraflask.core.container import Container
container = Container()
# Regular binding — a new instance every resolve
container.bind(PaymentGateway, StripeGateway)
# Singleton — the same instance every resolve
container.singleton(Logger, FileLogger)
# Instance — register an already-existing object
container.instance('config', config_object)
# Resolve
gateway = container.make(PaymentGateway)
# Automatic dependency injection based on type hints
class OrderService:
def __init__(self, gateway: PaymentGateway):
self.gateway = gateway
service = container.make(OrderService) # gateway is automatically resolvedProvide a different implementation depending on which class is currently being built — extremely useful when two parts of your application need the same interface but different implementations.
container.when(ReportController).needs(Logger).give(FileLogger)
container.when(UserController).needs(Logger).give(DatabaseLogger)
report_controller = container.make(ReportController) # logger -> FileLogger
user_controller = container.make(UserController) # logger -> DatabaseLoggerRegister several bindings under a single "tag", then resolve all of them at once — great for patterns like a list of report generators, a list of payment providers, etc.
container.tag([PdfReport, CsvReport, ExcelReport], 'reports')
for report in container.tagged('reports'):
report.generate()A Service Provider is the central place for registering (register()) and booting (boot()) services into the container. All of Laraflask's core features (DB, Cache, Auth, Queue, etc.) are booted through built-in providers; you add your own providers for application logic.
python artisan.py make:provider AppServiceProvider# app/Providers/AppServiceProvider.py
from laraflask.core.providers import ServiceProvider
class AppServiceProvider(ServiceProvider):
def register(self):
# Register container bindings here
self.app.singleton('payment', lambda app: StripeGateway())
def boot(self):
# Logic that requires every other provider to already be registered
passRegister your provider in config/app.py:
config = {
'providers': [
'app.Providers.AppServiceProvider.AppServiceProvider',
'app.Providers.AuthServiceProvider.AuthServiceProvider',
'app.Providers.EventServiceProvider.EventServiceProvider',
],
}Built-in framework providers (automatically booted on app.bootstrap()):
| Provider | Responsibility |
|---|---|
RouteServiceProvider |
Loads route files |
DatabaseServiceProvider |
Database connection |
CacheServiceProvider |
Cache driver configuration |
SessionServiceProvider |
Flask session configuration |
AuthServiceProvider |
Authentication guards + Gate |
ValidationServiceProvider |
Validator binding |
EventServiceProvider |
Listener registration |
QueueServiceProvider |
Queue connection configuration |
NotificationServiceProvider |
NotificationSender binding |
StorageServiceProvider |
Storage disk configuration |
SchedulerServiceProvider |
Schedule binding |
Every file in config/*.py is automatically loaded and accessed via dot-notation:
from laraflask.core.config import Config
config = Config('/path/to/config')
config.get('database.connections.mysql.host')
config.get('app.providers')
config.get('cache.default', 'file') # with a default value
config.set('app.debug', True) # set at runtime
config.has('mail.mailers.smtp')Every config file simply exports a dict named config:
# config/app.py
import os
config = {
'name': os.getenv('APP_NAME', 'Laraflask'),
'env': os.getenv('APP_ENV', 'production'),
'debug': os.getenv('APP_DEBUG', 'false').lower() == 'true',
'providers': [...],
'aliases': {
'Auth': 'laraflask.auth.auth.Auth',
'DB': 'laraflask.orm.db.DB',
# ...
},
}Routes are defined in routes/web.py (session-based) and routes/api.py (stateless). The Route object is automatically injected by the framework — no manual import needed.
Route.get('/', lambda: 'Hello Laraflask')
Route.post('/posts', 'App\\Controllers\\PostController@store')
Route.put('/posts/{id}', 'App\\Controllers\\PostController@update')
Route.patch('/posts/{id}', 'App\\Controllers\\PostController@update')
Route.delete('/posts/{id}', 'App\\Controllers\\PostController@destroy')
Route.any('/webhook', 'App\\Controllers\\WebhookController@handle')
Route.match(['GET', 'POST'], '/contact', 'App\\Controllers\\ContactController@handle')Route.get('/posts/{id}', 'App\\Controllers\\PostController@show')
Route.get('/posts/{id}/comments/{comment}', 'App\\Controllers\\CommentController@show')Route.get('/dashboard', 'App\\Controllers\\DashboardController@index').name('dashboard')
# Elsewhere:
router.url_for('dashboard')Route.get('/profile', 'App\\Controllers\\ProfileController@show').middleware('auth')
Route.post('/admin/users', 'App\\Controllers\\AdminController@store').middleware('auth', 'admin')with Route.group({'prefix': '/dashboard', 'middleware': ['auth']}):
Route.get('/', 'App\\Controllers\\DashboardController@index').name('dashboard')
Route.get('/settings', 'App\\Controllers\\DashboardController@settings')
# Or as separate context managers
with Route.prefix('/api/v1'):
Route.get('/users', 'App\\Controllers\\UserController@index')
with Route.middleware('auth', 'verified'):
Route.get('/billing', 'App\\Controllers\\BillingController@index')Generate all 7 standard CRUD routes (index, create, store, show, edit, update, destroy) in a single call:
Route.resource('posts', 'App\\Controllers\\PostController')
# Restrict which methods are generated
Route.resource('users', 'App\\Controllers\\UserController', only=['index', 'show'])
Route.resource('comments', 'App\\Controllers\\CommentController', except_=['create', 'edit'])
# API resource — automatically excludes create/edit (HTML forms)
Route.api_resource('posts', 'App\\Controllers\\Api\\PostController')| Method | URI | Action | Route Name |
|---|---|---|---|
| GET | /posts |
index |
posts.index |
| GET | /posts/create |
create |
posts.create |
| POST | /posts |
store |
posts.store |
| GET | /posts/{id} |
show |
posts.show |
| GET | /posts/{id}/edit |
edit |
posts.edit |
| PUT | /posts/{id} |
update |
posts.update |
| DELETE | /posts/{id} |
destroy |
posts.destroy |
router.middleware_group('web', ['session', 'csrf'])
router.middleware_group('api', ['throttle:60,1'])
router.alias_middleware('auth', AuthMiddleware)
router.alias_middleware('admin', AdminMiddleware)Route.redirect('/old-path', '/new-path')
Route.permanent_redirect('/old-path', '/new-path') # 301
Route.view('/about', 'pages.about', {'title': 'About Us'})python artisan.py route:listControllers receive dependencies through the container — including FormRequest instances, which are automatically validated before the method runs.
# app/Controllers/PostController.py
from app.Controllers.Controller import Controller
from app.Models.Post import Post
from app.Requests.StorePostRequest import StorePostRequest
from laraflask.api.api import ApiResponse
class PostController(Controller):
def index(self):
posts = Post.where('published', True).paginate(15)
return ApiResponse.success(posts.to_dict())
def show(self, id):
post = Post.find_or_fail(id)
return ApiResponse.success(post.to_dict())
def store(self):
data = StorePostRequest().validate()
post = Post.create(data)
return ApiResponse.success(post.to_dict(), status=201)
def update(self, id):
post = Post.find_or_fail(id)
post.update(StorePostRequest().validated())
return ApiResponse.success(post.to_dict())
def destroy(self, id):
Post.find_or_fail(id).delete()
return ApiResponse.success(message='Post deleted')An action can be a string ("Namespace\\Controller@method"), a tuple (ControllerClass, 'method'), or a callable directly (closure/function) — all three are resolved through the container, so dependencies like FormRequest are automatically injected.
EloquentPy is Laraflask's Active-Record ORM, built on top of SQLAlchemy but with a Laravel Eloquent-style API.
# app/Models/Post.py
from laraflask.orm.model import Model
class Post(Model):
__table__ = 'posts' # optional — defaults to the pluralized class name (Post -> posts)
__primary_key__ = 'id' # default: 'id'
__fillable__ = ['title', 'body', 'user_id']
__hidden__ = ['internal_notes']
__timestamps__ = True # default: True (created_at, updated_at handled automatically)
__soft_deletes__ = False # set True if the table has a deleted_at column
__appends__ = ['excerpt'] # extra accessors that also appear in to_dict()
__casts__ = {
'metadata': 'json',
'published_at': 'datetime',
'is_featured': 'boolean',
}
# Mutator — automatically called when the attribute is set
def set_title_attribute(self, value: str) -> str:
return value.strip().title()
# Accessor — automatically called on to_dict() / attribute access
def get_excerpt_attribute(self) -> str:
body = self._attributes.get('body', '')
return body[:100] + '...' if len(body) > 100 else body
# Relationships
@property
def author(self):
from app.Models.User import User
return self.belongs_to(User, foreign_key='user_id')
@property
def comments(self):
from app.Models.Comment import Comment
return self.has_many(Comment)Instead of writing __table__/__hidden__/__fillable__ manually, you can use decorators (adapting Laravel 13's PHP Attributes to Python idioms) — both styles can be freely mixed, and existing models keep working unchanged:
from laraflask.orm.model import Model, table, hidden, fillable
@table(name='posts', primary_key='post_id')
@hidden('internal_notes')
@fillable('title', 'body', 'user_id')
class Post(Model):
pass# Create
post = Post.create(title='Hello World', body='...', user_id=1)
# Read
post = Post.find(1)
post = Post.find_or_fail(1) # raises ModelNotFoundException if missing
all_posts = Post.all()
all_posts = Post.all(as_collection=True) # returned as a Collection (see Collection section)
# Update
post.title = 'Updated Title'
post.save()
# or directly through a query
Post.where('id', 1).update({'title': 'Updated Title'})
# Delete
post.delete()
# or
Post.where('published', False).delete()
# firstOrCreate / updateOrCreate
post = Post.first_or_create({'slug': 'hello-world'}, {'title': 'Hello World'})
post = Post.update_or_create({'slug': 'hello-world'}, {'title': 'Updated Title'})post.fill({'title': 'New Title', 'body': 'New body'}) # mass-assignment via __fillable__
post.is_dirty() # True if any attribute has changed since loading
post.is_dirty('title') # check a specific attribute
post.get_dirty() # dict of changed attributes
post.fresh() # re-fetch from the DB (new instance)
post.refresh() # re-fetch from the DB (same instance, in-place)
post.to_dict()
post.to_json()class Post(Model):
__soft_deletes__ = True # the table must have a deleted_at column
post.delete() # soft delete (sets deleted_at)
post.restore() # restore (deleted_at = None)
Post.all() # automatically excludes soft-deleted records
Post.with_trashed().get() # include soft-deleted records
Post.only_trashed().get() # only soft-deleted recordsPost.where('published', True).get()
Post.where('views', '>', 100).get()
Post.where('title', 'LIKE', '%laravel%').get()
Post.where_in('category_id', [1, 2, 3]).get()
Post.where_not_in('status', ['draft', 'archived']).get()
Post.where_null('deleted_at').get()
Post.where_not_null('published_at').get()
Post.where_between('views', [100, 1000]).get()
Post.where_like('title', '%python%').get()
Post.query().where('a', 1).or_where('b', 2).get()
Post.order_by('created_at', 'DESC').get()
Post.order_by_desc('views').get()
Post.latest().get() # order by created_at DESC
Post.oldest().get() # order by created_at ASC
Post.limit(10).get()
Post.query().offset(20).limit(10).get()
Post.query().select('id', 'title').distinct().get()
Post.query().join('users', 'posts.user_id', '=', 'users.id').get()
Post.query().left_join('comments', 'posts.id', '=', 'comments.post_id').get()
Post.query().with_relations('author', 'comments').get() # eager loadingPost.count()
Post.where('published', True).count()
Post.query().sum('views')
Post.query().avg('rating')
Post.query().max('views')
Post.query().min('views')
Post.query().exists()
Post.query().doesnt_exist()result = Post.where('published', True).paginate(per_page=15, page=2)
# result = {
# 'data': [...],
# 'total': 142,
# 'per_page': 15,
# 'current_page': 2,
# 'last_page': 10,
# 'from': 16,
# 'to': 30,
# }def process(posts):
for post in posts:
post.send_to_search_index()
Post.query().chunk(100, process) # process 100 rows per batch
Post.query().each(lambda post: post.reindex()) # iterate one at a timePost.query().pluck('title') # ['Title 1', 'Title 2', ...]
Post.query().pluck('title', key='id') # {1: 'Title 1', 2: 'Title 2'}
Post.query().to_list() # [{'id': 1, 'title': '...'}, ...]post.author # belongs_to -> Optional[User]
post.comments # has_many -> List[Comment]
user.profile # has_one -> Optional[Profile]
⚠️ Honest note:belongs_to_many(many-to-many relationships through a pivot table) is currently not implemented and will raiseNotImplementedErrorif called. Use a manual query viaDBorRawQueryBuilderas a temporary workaround.
Observers separate lifecycle logic (what happens when a model is created, updated, deleted, etc.) from the Model class itself.
python artisan.py make:observer PostObserver --model Post# app/Observers/PostObserver.py
from app.Models.Post import Post
from laraflask.orm.observer import Observer
class PostObserver(Observer):
def created(self, model: Post) -> None:
send_new_post_notification(model)
def updated(self, model: Post) -> None:
clear_post_cache(model.id)
def deleted(self, model: Post) -> None:
cleanup_post_assets(model)# Register it (e.g. in a ServiceProvider.boot())
Post.observe(PostObserver)Available hooks: creating, created, updating, updated, deleting, deleted, saving, saved, restoring, restored.
⚠️ Honest note:Model.observe()wires an Observer's hooks to the existingModelCreating/ModelCreated/etc. events inlaraflask.events.dispatcher, but those events are still not automatically dispatched fromModel.save()/delete(). For now, you need to dispatch them manually for the observer to fire:from laraflask.events.dispatcher import Events, ModelCreating, ModelCreated post = Post(title='Hello') Events.dispatch(ModelCreating(post)) post.save() Events.dispatch(ModelCreated(post)) # PostObserver.created() fires here
QueryBuilder.get() and Model.all() still return a plain Python list by default (backward compatible). Pass as_collection=True to get results back as a chainable Collection — inspired by Illuminate\Support\Collection.
from laraflask.core.collection import Collection
posts = Post.all(as_collection=True)
posts.map(lambda p: p.title.upper()) \
.filter(lambda p: p.published) \
.sort_by('created_at') \
.pluck('title') \
.to_list()
# Available methods:
Collection([1, 2, 3]).map(lambda x: x * 2)
Collection([1, 2, 3]).filter(lambda x: x > 1)
Collection([1, 2, 3]).reduce(lambda carry, x: carry + x, 0)
Collection([1, 2, 3]).each(lambda x: print(x))
Collection([{'name': 'Rio'}]).pluck('name')
Collection([{'role': 'admin'}, {'role': 'user'}]).group_by('role')
Collection([3, 1, 2]).sort_by(lambda x: x)
Collection([1, 2, 3, 4, 5]).chunk(2)
Collection([[1, 2], [3, [4, 5]]]).flatten()
Collection([1, 1, 2, 3]).unique()
Collection([1, 2, 3]).contains(2)
Collection([1, 2, 3]).first()
Collection([1, 2, 3]).last()
Collection([1, 2, 3]).sum()
Collection([1, 2, 3]).avg()
Collection([1, 2, 3]).count()
Collection([1, 2, 3]).tap(lambda c: print(c.to_list())) # side effect without altering the chain
Collection([1, 2, 3]).pipe(lambda c: c.sum()) # transforms the chain
Collection([1, 2, 3]).when(True, lambda c: c.map(lambda x: x * 10))Additional constructors:
Collection.make([1, 2, 3])
Collection.times(5, lambda n: n * 10) # [10, 20, 30, 40, 50]
Collection.range(1, 5) # [1, 2, 3, 4, 5]# database/migrations/2024_01_01_000000_create_posts_table.py
from laraflask.orm.migration import Migration, Schema
class Migration_Posts(Migration):
def up(self):
Schema.create('posts', lambda table: [
table.id(),
table.string('title'),
table.text('body'),
table.foreign_id('user_id').references('users', 'id'),
table.boolean('published').default(False),
table.json('metadata').nullable(),
table.timestamps(),
table.soft_deletes(),
])
def down(self):
Schema.drop_if_exists('posts')Column types available on Blueprint: id(), integer(), big_integer(), small_integer(), string(), text(), boolean(), float_(), decimal(), date(), datetime(), timestamp(), json(), binary(), foreign_id(), vector() (see Vector Similarity Search), timestamps(), soft_deletes().
Column modifiers (chainable): .nullable(), .default(value), .unique(), .unsigned(), .comment().
Other Schema operations:
Schema.table('posts', lambda table: [table.string('slug').nullable()]) # add a column
Schema.drop('posts')
Schema.drop_if_exists('posts')
Schema.rename('posts', 'articles')
Schema.has_table('posts')
Schema.has_column('posts', 'slug')
Schema.enable_pgvector() # run once before using vector columnsFor semantic (embedding-based) search on PostgreSQL via pgvector:
pip install laraflask-core[vector]# Migration
Schema.enable_pgvector() # CREATE EXTENSION IF NOT EXISTS vector
Schema.create('documents', lambda table: [
table.id(),
table.string('title'),
table.vector('embedding', dimensions=1536), # default 1536 (matches OpenAI text-embedding-3-small)
])# Query — find documents most similar to an embedding vector
from app.Models.Document import Document
query_vector = get_embedding("how to build an API with Flask") # from your embedding provider of choice
results = (Document.query()
.order_by_similarity('embedding', query_vector, limit=5, metric='cosine')
.get())Supported metric values: 'cosine' (default — suited for normalized embeddings), 'l2' (Euclidean), 'inner_product'.
order_by_similarity() automatically sets limit() to the limit parameter unless one was already set explicitly, and can still be combined with other .where() calls:
(Document.query()
.where('category', 'tutorial')
.order_by_similarity('embedding', query_vector, limit=10)
.get())from laraflask.validation.validator import Validator
validator = Validator(data, {
'email': 'required|email|unique:users',
'name': 'required|string|min:2|max:100',
'age': 'required|integer|min:18',
'role': 'nullable|string',
})
if validator.fails():
return validator.errors()
# {'email': ['The email field is required.'], ...}
validated = validator.validate() # raises ValidationException on failure, otherwise returns valid data| Rule | Description |
|---|---|
required |
Field must be present |
nullable |
Field may be None (skips other rules when empty) |
sometimes |
Only validates if the field is present in the data |
string |
Must be a string |
integer |
Must be castable to int |
numeric |
Must be castable to float |
boolean |
Must be a boolean-like value |
array |
Must be a list/tuple |
email |
Must be a valid email format |
url |
Must be a valid URL format |
min:N |
Minimum length/value/item count |
max:N |
Maximum length/value/item count |
unique:table,column,except_id,id_column |
Value must be unique in the table |
exists:table,column |
Value must exist in the table |
date |
Must be a valid ISO date format |
before:date |
Date must be before the given value |
after:date |
Date must be after the given value |
file |
Must be an uploaded file |
image |
Must be an image file (jpg/png/gif/bmp/svg/webp) |
mimes:jpg,png,pdf |
Must have one of the given file extensions |
json |
Must be a valid JSON string |
ip |
Must be a valid IP address |
uuid |
Must be a valid UUID |
required_if:field,value |
Required if another field has a given value |
required_unless:field,value |
Required unless another field has a given value |
required_with:field1,field2 |
Required if any of the other fields are present |
prohibited |
Field must not be present |
validator = Validator(data, rules, messages={
'email.required': 'The email address is required.',
'min': 'The :attribute value is too short.',
}, attributes={
'email': 'email address',
})Validator.extend('phone_id', lambda field, value, params: (
bool(re.match(r'^08\d{8,11}$', str(value))),
'The :attribute must be a valid Indonesian phone number.'
))
# Use it like any other rule
Validator(data, {'phone': 'required|phone_id'})For a more organized, reusable rule, generate a class that wraps this same Validator.extend() pattern:
python artisan.py make:rule PhoneIdRule# app/Rules/PhoneIdRule.py
from laraflask.validation.validator import Validator
class PhoneIdRule:
rule_name = 'phone_id_rule'
@classmethod
def register(cls) -> None:
Validator.extend(cls.rule_name, cls.validate)
@staticmethod
def validate(field: str, value, params) -> tuple:
passed = bool(re.match(r'^08\d{8,11}$', str(value)))
return passed, f"The {field} field must be a valid Indonesian phone number."# Register once, e.g. in a ServiceProvider.boot()
PhoneIdRule.register()
# Then use it like any other rule
Validator(data, {'phone': 'required|phone_id_rule'})validator.sometimes('company_name', 'required|string', callback=lambda data: data.get('is_business'))
validator.after(lambda v: v._errors.setdefault('custom', []).append('Additional error') if some_condition else None)python artisan.py make:request StorePostRequest# app/Requests/StorePostRequest.py
from laraflask.validation.validator import FormRequest
class StorePostRequest(FormRequest):
def authorize(self) -> bool:
from laraflask.auth.auth import Auth
return Auth.check()
def rules(self) -> dict:
return {
'title': 'required|string|max:255',
'body': 'required|string',
'category_id': 'required|integer|exists:categories,id',
}
def messages(self) -> dict:
return {'title.required': 'The title field is required.'}# In a Controller
def store(self):
data = StorePostRequest().validate() # 403 if authorize() is False, ValidationException on failure
# or
request = StorePostRequest()
validated = request.validated()
errors = request.errors()Laraflask supports 3 guards: session (web), JWT (stateless API), and API key.
# config/auth.py
config = {
'defaults': {'guard': 'web'},
'guards': {
'web': {'driver': 'session', 'provider': 'users'},
'api': {'driver': 'jwt', 'provider': 'users', 'ttl': 60},
},
'providers': {
'users': {'driver': 'eloquent', 'model': 'app.Models.User.User'},
},
}from laraflask.auth.auth import Auth
if Auth.attempt({'email': email, 'password': password}):
return redirect('/dashboard')
Auth.attempt({'email': email, 'password': password}, remember=True) # "remember me"
Auth.login(user) # log in manually without checking a password
Auth.login_using_id(user_id)
Auth.logout()
Auth.user() # the current User, or None
Auth.check() # True if logged in
Auth.guest() # True if not logged in
Auth.id() # the current user's IDguard = Auth.guard('api')
token = guard.attempt({'email': email, 'password': password}) # returns a JWT string or None
# On subsequent requests, send the header: Authorization: Bearer <token>
user = guard.user() # automatically decoded from the headerfrom laraflask.auth.auth import Hash
hashed = Hash.make('secret123') # bcrypt (falls back to salted sha256 if bcrypt isn't installed)
Hash.check('secret123', hashed) # True
Hash.needs_rehash(hashed, rounds=12)💡 Passwords are hashed automatically via the
set_password_attributemutator on the built-inUsermodel — you don't need to callHash.make()manually when creating a new user.
Route.get('/dashboard', '...').middleware('auth') # AuthMiddleware
Route.get('/login', '...').middleware('guest') # GuestMiddleware (redirects if already logged in)from laraflask.auth.auth import auth_required, can
@auth_required()
def dashboard():
...
@auth_required(guard='api')
def api_profile():
...
@can('update', model_arg='post')
def update_post(post):
...from laraflask.auth.auth import Gate
Gate.define('edit-settings', lambda user: user.is_admin())
Gate.define('view-post', lambda user, post: post.published or post.user_id == user.id)
Gate.allows('edit-settings') # bool, checks the currently logged-in user
Gate.denies('edit-settings')
Gate.authorize('edit-settings') # automatically aborts(403) if denied
Gate.can('view-post', post) # alias for allows()
Gate.for_user(other_user).can('edit-settings') # check for a specific user (not the logged-in one)Gate.before(lambda user, ability: True if user.is_super_admin() else None)
Gate.after(lambda user, ability, result: log_authorization(user, ability, result))python artisan.py make:policy PostPolicy --model Post# app/Policies/PostPolicy.py
from laraflask.auth.auth import Policy
class PostPolicy(Policy):
def view(self, user, post):
return post.published or post.user_id == user.id
def update(self, user, post):
return user.id == post.user_id or user.is_admin()
def delete(self, user, post):
return user.id == post.user_id or user.is_admin()# Registration (usually in AuthServiceProvider)
Gate.policy(Post, PostPolicy)
# Usage
Gate.allows('update', post) # automatically looks up PostPolicy.update()| Middleware | Alias | Description |
|---|---|---|
AuthMiddleware |
auth |
Requires login, redirects/401s if not logged in |
GuestMiddleware |
guest |
Only for users who aren't logged in |
CsrfMiddleware |
csrf |
Verifies the CSRF token (form/header) |
PreventRequestForgeryMiddleware |
— | CSRF + origin-aware verification (see Security) |
ThrottleMiddleware |
throttle:60,1 |
Rate limiting (60 requests per 1 minute) |
CorsMiddleware |
cors |
CORS headers |
SecureHeadersMiddleware |
— | Security headers (X-Frame-Options, etc.) |
TrimStringsMiddleware |
— | Automatically trims whitespace from input |
ConvertEmptyStringsToNullMiddleware |
— | Converts empty strings to None |
SessionMiddleware |
session |
Initializes the session |
LogRequestMiddleware |
— | Logs every request |
ForceHttpsMiddleware |
— | Redirects HTTP → HTTPS |
MaintenanceModeMiddleware |
— | Maintenance mode |
SubstituteBindingsMiddleware |
— | Resolves models from route parameters |
VerifySignedMiddleware |
— | Verifies signed URLs |
# app/Middleware/EnsureUserIsAdmin.py
from laraflask.middleware.middleware import Middleware
class EnsureUserIsAdmin(Middleware):
def handle(self, request, next):
from laraflask.auth.auth import Auth
from flask import abort # abort() is a Werkzeug/Flask helper — use inline only inside handle()
user = Auth.user()
if not user or not user.is_admin():
abort(403)
return next(request)# Register an alias in RouteServiceProvider / bootstrap
router.alias_middleware('admin', EnsureUserIsAdmin)
# Use it on a route
Route.get('/admin', '...').middleware('admin')from laraflask.security.security import CsrfToken
token = CsrfToken.generate(session)
CsrfToken.verify(token, session)
CsrfToken.regenerate(session) # call after login to prevent session fixationIn templates, include the token via the @csrf directive (see Template Engine).
An extra layer on top of regular token-based CSRF — verifies the Origin/Referer header against a list of trusted hosts. Optional — the old CsrfMiddleware remains the default and is unaffected.
from laraflask.security.security import PreventRequestForgery
guard = PreventRequestForgery(trusted_origins=['myapp.com', '*.myapp.com'])
guard.verify(token, session, request) # True only if both the token AND the origin are valid# Use it as a middleware (an alternative to CsrfMiddleware)
from laraflask.middleware.middleware import PreventRequestForgeryMiddleware
router.alias_middleware('csrf-strict', PreventRequestForgeryMiddleware(trusted_origins=['myapp.com']))from laraflask.security.security import XSS
XSS.clean(user_input) # strips <script> tags, event handlers, javascript: URIs
XSS.escape(user_input) # HTML-escape
XSS.sanitize_url(url) # blocks javascript:/data: URIs
XSS.strip_tags(html, allowed=['p', 'b'])from laraflask.security.security import SqlSafe
SqlSafe.is_suspicious(user_input) # heuristic check (parameterized queries remain the primary defense)
SqlSafe.quote(value)
⚠️ EloquentPy/QueryBuilder already uses parameterized queries by default —SqlSafeis only relevant for manual raw SQL.
from laraflask.security.security import Crypt
crypt = Crypt() # uses APP_KEY from .env
encrypted = crypt.encrypt({'user_id': 1, 'role': 'admin'})
data = crypt.decrypt(encrypted)
crypt.encrypt_string('secret')
crypt.decrypt_string(encrypted_string)Requires
pip install cryptographyfor real AES encryption — without it, falls back to simple XOR (not safe for production).
from laraflask.security.security import PasswordPolicy
policy = PasswordPolicy(min_length=8, require_uppercase=True, require_numbers=True)
policy.validate('weak') # ['Password must be at least 8 characters.', ...]
policy.passes('Str0ngPass!') # True
policy.strength_score('Str0ngPass!') # 0-5
policy.strength_label('Str0ngPass!') # 'Strong'from laraflask.security.security import SignedUrl
signer = SignedUrl()
url = signer.create('/verify-email/123', expiry=3600)
signer.verify(url) # False if expired or the signature doesn't matchfile (default), redis, database, array (in-memory, for testing).
# config/cache.py
config = {
'default': 'file',
'stores': {
'file': {'driver': 'file', 'path': 'storage/cache/data'},
'redis': {'driver': 'redis', 'host': '127.0.0.1', 'port': 6379, 'database': 1},
'array': {'driver': 'array'},
'database': {'driver': 'database', 'table': 'cache'},
},
}from laraflask.cache.cache import Cache
Cache.put('key', 'value', seconds=3600)
Cache.get('key')
Cache.get('key', default='fallback')
Cache.has('key')
Cache.forget('key')
Cache.flush()
Cache.remember('expensive_key', 3600, lambda: compute_expensive_value())
Cache.remember_forever('static_key', lambda: compute_value())
Cache.increment('visits')
Cache.decrement('stock', 5)
# Extend the TTL without re-fetching the value — a single round-trip to the backend
Cache.touch('session:abc', 9999)
Cache.touch()is a native single round-trip on Redis (EXPIRE); a metadata-only update (no value unpickling) on the File/Array/Database drivers; a custom driver that doesn't overridetouch()automatically falls back to get+put.
Cache.tags(['posts', 'user:1']).put('post:1', data, seconds=3600)
Cache.tags(['posts']).flush() # remove every entry tagged 'posts'# app/Events/PostPublished.py
from laraflask.events.dispatcher import Event
class PostPublished(Event):
def __init__(self, post):
self.post = post# app/Listeners/SendPostPublishedNotification.py
from laraflask.events.dispatcher import Listener
class SendPostPublishedNotification(Listener):
def handle(self, event):
for subscriber in event.post.author.subscribers:
subscriber.notify(PostPublishedNotification(event.post))# app/Providers/EventServiceProvider.py
from laraflask.events.dispatcher import Events
class EventServiceProvider(ServiceProvider):
def boot(self):
Events.listen(PostPublished, SendPostPublishedNotification)Events.dispatch(PostPublished(post))RequestReceived, ResponseSent, ModelCreating/ModelCreated, ModelUpdating/ModelUpdated, ModelDeleting/ModelDeleted, ModelSaving/ModelSaved, UserRegistered, UserLoggedIn, UserLoggedOut, JobProcessing/JobProcessed/JobFailed, MessageSending/MessageSent.
⚠️ Honest note: Model lifecycle events (ModelCreating,ModelCreated, etc.) are already defined as classes, but are not yet automatically dispatched fromModel.save()/delete(). For now, you need to dispatch them manually if you need this behavior:Events.dispatch(ModelCreating(post)) post.save() Events.dispatch(ModelCreated(post))
from laraflask.events.dispatcher import EventSubscriber
class UserEventSubscriber(EventSubscriber):
def subscribe(self, events):
events.listen(UserRegistered, self.on_registered)
events.listen(UserLoggedIn, self.on_login)
def on_registered(self, event): ...
def on_login(self, event): ...# app/Jobs/SendInvoiceEmail.py
from laraflask.queue.queue import Job
class SendInvoiceEmail(Job):
queue = 'emails'
tries = 3
backoff = 30 # seconds, delay before retrying
def __init__(self, invoice_id):
self.invoice_id = invoice_id
def handle(self):
invoice = Invoice.find(self.invoice_id)
# ... send the email
def failed(self, exception):
# called after all attempts have failed
logger.error(f"Failed to send invoice: {exception}")from laraflask.queue.queue import Queue
Queue.dispatch(SendInvoiceEmail(invoice_id=1))
Queue.push(SendInvoiceEmail(invoice_id=1), queue='emails', delay=0)
Queue.later(60, SendInvoiceEmail(invoice_id=1)) # 60-second delayRegister the default connection/queue for a Job class once, typically in QueueServiceProvider.boot() — so dispatch() calls don't need to repeat the configuration every time:
from laraflask.queue.queue import Queue
Queue.route(SendInvoiceEmail, connection='redis', queue='high')
# dispatch() now automatically uses the 'redis' connection and 'high' queue
Queue.dispatch(SendInvoiceEmail(invoice_id=1))If a job instance already has
self.queueexplicitly overridden (the old way), that value still takes priority over the result ofroute()— 100% backward compatible.
python artisan.py queue:work
python artisan.py queue:work --queue=emails --sleep=3 --max-jobs=100
python artisan.py queue:listenThe Worker gracefully catches SIGTERM/SIGINT. If the currently-running job inherits from Interruptible, its interrupted() method is called before the worker actually shuts down — a chance to save progress or release a lock.
from laraflask.queue.queue import Job, Interruptible
class ProcessLargeVideo(Job, Interruptible):
def handle(self):
self.lock = acquire_lock(self.video_id)
for chunk in self.chunks:
process_chunk(chunk)
save_progress(self.video_id, chunk.index)
def interrupted(self, signal):
release_lock(self.lock)
logger.info(f"Job interrupted by signal {signal}, progress already saved")sync (immediate execution, for development/testing), database, redis.
⚠️ Honest note: Job chaining (Bus.chain(...)) and job batching (Bus.batch(...).then(...).catch(...)), which were once planned, are not yet implemented in the current codebase.
A cron replacement defined directly in Python code, similar to app/Console/Kernel.php in Laravel.
# app/Console/kernel.py (or routes/console.py)
from laraflask.scheduler.schedule import Schedule
Schedule.command('emails:send').daily_at('09:00')
Schedule.call(lambda: cleanup_old_sessions()).hourly()
Schedule.job(GenerateDailyReport()).daily_at('23:30').timezone('Asia/Jakarta')schedule = Schedule.command('report:generate')
schedule.every_minute()
schedule.every_two_minutes()
schedule.every_five_minutes()
schedule.every_ten_minutes()
schedule.every_fifteen_minutes()
schedule.every_thirty_minutes()
schedule.hourly()
schedule.hourly_at(30)
schedule.every_two_hours()
schedule.every_three_hours()
schedule.every_six_hours()
schedule.daily()
schedule.daily_at('13:00')
schedule.twice_daily(1, 13)
schedule.weekly()
schedule.weekly_on(1, '8:00')
schedule.monthly()
schedule.monthly_on(1, '0:0')
schedule.quarterly()
schedule.yearly()
schedule.yearly_on(1, 1, '0:0')
schedule.cron('*/5 * * * *') # custom cron expressionschedule.weekdays()
schedule.weekends()
schedule.sundays()
schedule.mondays()
schedule.tuesdays()
schedule.wednesdays()
schedule.thursdays()
schedule.fridays()
schedule.saturdays()Schedule.command('report:generate') \
.daily() \
.timezone('Asia/Jakarta') \
.without_overlapping() \
.run_in_background() \
.even_in_maintenance_mode() \
.when(lambda: is_business_day()) \
.description('Generate the daily report') \
.send_output_to('storage/logs/report.log')python artisan.py schedule:run # run by the system cron every minute
python artisan.py schedule:work # daemon mode — a cron alternative for developmentmail, sms (Twilio), telegram, whatsapp (Twilio), database, push (FCM).
# app/Notifications/InvoicePaid.py
from laraflask.notifications.notification import Notification, MailMessage, TelegramMessage
class InvoicePaid(Notification):
def __init__(self, invoice):
self.invoice = invoice
def via(self, notifiable) -> list:
return ['mail', 'database']
def to_mail(self, notifiable) -> MailMessage:
return (MailMessage()
.subject('Invoice Paid')
.greeting(f'Hello {notifiable.name},')
.line(f'Invoice #{self.invoice.id} has been paid.')
.action('View Invoice', f'/invoices/{self.invoice.id}')
.success())
def to_array(self, notifiable) -> dict:
return {'invoice_id': self.invoice.id, 'amount': self.invoice.amount}# Via a model that uses the Notifiable mixin
class User(Model, Notifiable):
...
user.notify(InvoicePaid(invoice))
user.notify_now(InvoicePaid(invoice)) # immediately, bypassing the queue
# Via the global facade
from laraflask.notifications.notification import Notification_
Notification_.send(user, InvoicePaid(invoice))
Notification_.send([user1, user2], InvoicePaid(invoice)) # multiple recipients
# Notify an anonymous recipient (no Model)
Notification_.route('mail', 'someone@example.com').notify(InvoicePaid(invoice))user.unread_notifications()
user.read_notifications()
user.mark_all_as_read()MailMessage() \
.subject('Email Subject') \
.greeting('Hello!') \
.line('First line.') \
.line('Second line.') \
.action('Click Here', 'https://example.com') \
.salutation('Regards,\nThe Laraflask Team') \
.attach('/path/to/file.pdf') \
.success() # styling level: success/error/warninglocal (default), s3 (AWS S3 / S3-compatible: MinIO, Cloudflare R2).
# config/storage.py
config = {
'default': 'local',
'disks': {
'local': {'driver': 'local', 'root': 'storage/app', 'url': '/storage'},
'public': {'driver': 'local', 'root': 'storage/app/public', 'visibility': 'public'},
's3': {'driver': 's3', 'key': '...', 'secret': '...', 'bucket': '...', 'region': 'us-east-1'},
},
}from laraflask.storage.storage import Storage
Storage.put('avatars/user1.jpg', file_contents)
Storage.put_file('avatars', uploaded_file) # auto-generates the file name
Storage.put_file_as('avatars', uploaded_file, 'user1.jpg')
Storage.get('avatars/user1.jpg') # bytes
Storage.get_string('config.json') # str
Storage.exists('avatars/user1.jpg')
Storage.missing('avatars/user1.jpg')
Storage.delete('avatars/user1.jpg')
Storage.copy('a.jpg', 'b.jpg')
Storage.move('a.jpg', 'b.jpg')
Storage.size('avatars/user1.jpg')
Storage.url('avatars/user1.jpg')
Storage.temporary_url('avatars/user1.jpg', expiry=3600) # temporary signed URL
Storage.files('avatars') # list files (non-recursive)
Storage.all_files('avatars') # list files (recursive)
Storage.directories('avatars')
Storage.make_directory('new-folder')
Storage.delete_directory('old-folder')
Storage.disk('s3').put('backups/db.sql', data) # use a specific diskA Blade-style template engine, compiled down to Jinja2 (so every Jinja2 feature is still available). Template files use the .blade.html extension and live in resources/views/.
<!-- resources/views/layouts/app.blade.html -->
<!DOCTYPE html>
<html>
<head><title>@yield('title', 'Laraflask')</title></head>
<body>
@include('partials.navbar')
@yield('content')
</body>
</html><!-- resources/views/posts/show.blade.html -->
@extends('layouts.app')
@section('title', $post['title'])
@section('content')
<h1>{{ $post['title'] }}</h1>
<p>{!! $post['body_html'] !!}</p> {{-- unescaped output --}}
@auth
<a href="/posts/{{ $post['id'] }}/edit">Edit</a>
@endauth
@can('delete', $post)
<button>Delete</button>
@endcan
@foreach ($comments as $comment)
<p>{{ $comment['body'] }}</p>
@endforeach
@forelse ($related as $item)
<li>{{ $item['title'] }}</li>
@empty
<p>No related articles.</p>
@endforelse
<form method="POST" action="/posts/{{ $post['id'] }}">
@csrf
@method('PUT')
<button type="submit">Save</button>
</form>
@endsection@extends, @section/@endsection, @yield, @include, @if/@elseif/@else/@endif, @unless/@endunless, @foreach/@endforeach, @for/@endfor, @while/@endwhile, @forelse/@empty/@endforelse, @switch/@case/@break/@default/@endswitch, @csrf, @method, @auth/@endauth, @guest/@endguest, @can/@endcan, @cannot/@endcannot, @env/@endenv, @production/@endproduction, @push/@endpush, @stack, @once/@endonce, @verbatim/@endverbatim, @dump, @dd, @php/@endphp, @json, @class, @style, @checked, @selected, @disabled, @required, @error/@enderror.
def show(self, id):
post = Post.find_or_fail(id)
# Use self.view() from Controller base class (calls render_template internally)
return self.view('posts.show', {'post': post.to_dict()})from laraflask.api.api import ApiResponse
ApiResponse.success(data, message='OK', status=200)
ApiResponse.error(message='Not Found', status=404)
ApiResponse.paginated(items, total=100, per_page=15, page=1)python artisan.py make:resource PostResourcefrom laraflask.api.api import ApiResource
class PostResource(ApiResource):
def to_array(self) -> dict:
# self._resource holds the model/object passed into the constructor.
return {
'id': self._resource.id,
'title': self._resource.title,
'author': self._resource.author.name,
}
PostResource(post).to_response()
PostResource.collection(posts).to_response()JsonApiResource — JSON:API Specification
For APIs that follow the full JSON:API standard: the {data, included, links, meta} structure, sparse fieldsets, and relationship inclusion.
python artisan.py make:resource PostResource --jsonapifrom laraflask.api.jsonapi import JsonApiResource
class PostResource(JsonApiResource):
type_ = 'posts'
def attributes(self) -> dict:
return {'title': self.model.title, 'body': self.model.body}
def relationships(self) -> dict:
return {'author': self.model.author, 'comments': self.model.comments}
def resource_class_for(self, relation_name, related_model):
if relation_name == 'author':
return UserResource
if relation_name == 'comments':
return CommentResource
return JsonApiResource# Single resource
PostResource(post).to_response()
# -> {"data": {"type": "posts", "id": "1", "attributes": {...}, "relationships": {...}}}
# Collection
PostResource.collection(posts).to_response()
# -> {"data": [...], "included": [...]} (included is automatically deduplicated across items)Sparse Fieldsets (?fields[posts]=title):
GET /api/posts/1?fields[posts]=title
Only the title field appears in attributes.
Relationship Inclusion (?include=author,comments):
GET /api/posts/1?include=author,comments
The full author and comments resources are automatically added to the included member.
# Response with extra meta & links
PostResource(post).to_response(
meta={'request_id': 'abc-123'},
links={'self': '/api/posts/1'},
)from laraflask.api.api import RateLimiter
RateLimiter.attempt(key=f'login:{ip_address}', max_attempts=5, decay_seconds=60) # bool
RateLimiter.too_many_attempts(key, max_attempts=5)
RateLimiter.available_in(key) # seconds remaining until reset
RateLimiter.remaining_attempts(key, max_attempts=5)
RateLimiter.clear(key)
RateLimiter.for_('uploads', lambda request: RateLimiter.attempt(f'upload:{request.remote_addr}', 10, 60))Or via middleware: Route.get('/api/data', '...').middleware('throttle:60,1') (60 requests per 1 minute).
from laraflask.api.api import OpenApiGenerator
spec = OpenApiGenerator(title='My API', version='1.0.0', description='My API documentation')
spec.add_path('/posts', 'GET', summary='List all posts', tags=['Posts'])
spec.add_path('/posts/{id}', 'GET', summary='Get a single post', tags=['Posts'])
spec.add_schema('Post', {'type': 'object', 'properties': {'id': {'type': 'integer'}}})
spec.server('https://api.myapp.com', description='Production')
spec.to_dict() # the OpenAPI 3.0 spec as a dict
spec.to_json()
# Automatically registers /api/openapi.json and /api/docs (Swagger UI)
spec.register_routes(router, prefix='/api')pip install laraflask-core[websocket]from laraflask.ws.websocket import WebSocketManager
ws = WebSocketManager()
ws.init_app(flask_app)
@ws.on('chat:message')
def handle_message(data):
ws.to('room-1').emit('chat:message', data)from laraflask.ws.websocket import BroadcastEvent, Channel, PrivateChannel, Broadcast
class OrderShipped(BroadcastEvent):
def __init__(self, order):
self.order = order
def broadcast_on(self) -> list:
return [PrivateChannel(f'orders.{self.order.id}')]
def broadcast_with(self) -> dict:
return {'order_id': self.order.id, 'status': 'shipped'}
Broadcast.event(OrderShipped(order))ws.join_room('room-1', sid=request.sid)
ws.leave_room('room-1', sid=request.sid)
ws.clients_in_room('room-1')
ws.connected_count()from laraflask.ws.websocket import SSEManager
sse = SSEManager()
@app.route('/stream')
def stream():
return sse.stream(channel='notifications')
# Elsewhere (e.g. after a job finishes):
sse.publish('notifications', data=json.dumps({'message': 'Done!'}))# app/tests/Unit/test_post_model.py
from laraflask.testing.test_case import UnitTestCase
from app.Models.Post import Post
class PostModelTest(UnitTestCase):
def before_each(self):
self.post = Post(title='Hello', body='World')
def test_title_is_capitalized(self):
self.assertEqual(self.post.title, 'Hello')Use
before_each()/after_each()(notsetUp()/tearDown()directly — both are already called automatically byTestCase).
# app/tests/Feature/test_post_api.py
from laraflask.testing.test_case import FeatureTestCase
from app.Models.User import User
from app.Models.Post import Post
class PostApiTest(FeatureTestCase):
def test_guest_cannot_create_post(self):
response = self.post('/api/posts', {'title': 'Test'})
response.assert_status(401)
def test_authenticated_user_can_create_post(self):
user = self.create(User, name='Rio', email='rio@test.com')
response = self.acting_as(user).post('/api/posts', {
'title': 'New Title',
'body': 'Content body',
})
response.assert_created()
self.assert_database_has('posts', {'title': 'New Title'})
def test_api_with_jwt_token(self):
response = self.with_token('eyJ...').get('/api/profile')
response.assert_ok()self.get(uri, headers=...)
self.post(uri, data=...)
self.put(uri, data=...)
self.patch(uri, data=...)
self.delete(uri)
self.call(method, uri, data=...)
self.acting_as(user) # log in as a user (session guard)
self.acting_as_api(user) # log in as a user (JWT guard, auto-generates a token)
self.with_token(token) # set the Authorization: Bearer <token> headerresponse.assert_status(200)
response.assert_ok() # 200
response.assert_created() # 201
response.assert_no_content() # 204
response.assert_not_found() # 404
response.assert_forbidden() # 403
response.assert_unauthorized() # 401
response.json()
response.data
response.text
response.headersself.assert_database_has('posts', {'title': 'New Title'})
self.assert_database_missing('posts', {'title': 'Deleted'})
self.assert_database_count('posts', 5)
self.refresh_database() # reset the database to a clean stateSeeder — populate the database with initial/sample data:
python artisan.py make:seeder PostSeeder# database/seeders/PostSeeder.py
from laraflask.orm.seeder import Seeder
from database.factories.PostFactory import PostFactory
class PostSeeder(Seeder):
def run(self) -> None:
PostFactory().count(20).create()Run it via python artisan.py db:seed. A main DatabaseSeeder can call several seeders in sequence with self.call(...):
class DatabaseSeeder(Seeder):
def run(self) -> None:
self.call(UserSeeder, PostSeeder, CommentSeeder)Factory — generate dummy model instances, backed by Faker (pip install laraflask-core[testing]):
python artisan.py make:factory PostFactory --model Post# database/factories/PostFactory.py
from laraflask.orm.factory import Factory
from app.Models.Post import Post
class PostFactory(Factory):
model = Post
def definition(self) -> dict:
return {
'title': self.faker.sentence(),
'body': self.faker.paragraph(),
}| Method | Description |
|---|---|
.make() |
Build an instance without persisting it |
.create() |
Build an instance and save it to the database |
.count(n) |
Produce n instances instead of 1 (returns a list) |
.state(**overrides) |
Override specific attributes from definition(), chainable |
post = PostFactory().make() # instance only, not saved
post = PostFactory().create() # instance + saved to DB
posts = PostFactory().count(10).create() # 10 saved instances
post = PostFactory().state(title='Pinned Post').create() # override one field💡 In test classes that inherit
UnitTestCase/FeatureTestCase,self.create(Model, **attrs)/self.make(Model, **attrs)remain available as convenience shortcuts on top of any registered factory.
def test_publishing_post_sends_notification(self):
events = self.fake_events()
queue = self.fake_queue()
notifications = self.fake_notifications()
storage = self.fake_storage()
mail = self.fake_mail()
# ... run the code under test ...
events.assert_dispatched(PostPublished)
queue.assert_pushed(SendNewsletterJob)
queue.assert_not_pushed(SendSpamJob)python -m unittest discover -s app/tests/Unit
python -m unittest discover -s app/tests/Feature
pytest app/tests/ # if pytest is installed (more features: -k, -v, etc.)Every command is run via python artisan.py <command> from the project root.
| Command | Description |
|---|---|
make:model ModelName |
Create a new Model class |
make:controller ControllerName |
Create a new Controller class |
make:migration migration_name |
Create a new migration file |
make:middleware MiddlewareName |
Create a new Middleware class |
make:job JobName |
Create a new Job class |
make:event EventName |
Create a new Event class |
make:listener ListenerName |
Create a new Listener class |
make:notification NotificationName |
Create a new Notification class |
make:request RequestName |
Create a new FormRequest class in app/Requests |
make:policy PolicyName [--model ModelName] |
Create a new Policy class in app/Policies; with --model, the standard methods (view_any, view, create, update, delete, restore, force_delete) are type-hinted against that model |
make:resource ResourceName [--jsonapi] |
Create a new API resource class in app/Resources; defaults to ApiResource, or JsonApiResource with --jsonapi |
make:rule RuleName |
Create a new custom validation rule in app/Rules, ready to register via RuleName.register() |
make:provider ProviderName |
Create a new ServiceProvider class in app/Providers |
make:seeder SeederName |
Create a new database seeder in database/seeders |
make:factory FactoryName [--model ModelName] |
Create a new model factory in database/factories, wired to Faker |
make:observer ObserverName [--model ModelName] |
Create a new model observer in app/Observers |
make:command CommandName [--command-name name] |
Create a new custom Artisan command in app/Console |
python artisan.py make:request StorePostRequest
python artisan.py make:policy PostPolicy --model Post
python artisan.py make:resource PostResource --jsonapi
python artisan.py make:rule PhoneIdRule
python artisan.py make:provider PaymentServiceProvider
python artisan.py make:seeder PostSeeder
python artisan.py make:factory PostFactory --model Post
python artisan.py make:observer PostObserver --model Post
python artisan.py make:command SendDigestCommand --command-name digest:send| Command | Description |
|---|---|
migrate |
Run pending migrations |
migrate:rollback |
Roll back the last migration batch |
migrate:refresh |
Reset and re-run all migrations |
migrate:fresh |
Drop all tables and re-run migrations |
migrate:status |
Show the status of every migration |
db:seed |
Seed the database |
| Command | Description |
|---|---|
route:list |
List every registered route |
| Command | Description |
|---|---|
queue:work |
Process jobs from the queue (--queue=, --sleep=, --max-jobs=) |
queue:listen |
Listen on a specific queue |
schedule:run |
Run due scheduled commands (invoked by cron every minute) |
schedule:work |
Run the scheduler as a daemon (a cron alternative) |
| Command | Description |
|---|---|
tinker |
Open the interactive REPL (see the Tinker section) |
cache:clear |
Clear the entire application cache |
serve |
Start the development server |
key:generate |
Generate a new APP_KEY |
env:decrypt |
Decrypt an encrypted environment file |
about |
Show basic application information |
python artisan.py make:model Post
python artisan.py make:controller PostController
python artisan.py make:migration create_posts_table
python artisan.py migrate
python artisan.py route:list
python artisan.py queue:work --queue=emails --sleep=3
python artisan.py serve --host=0.0.0.0 --port=8000python artisan.py tinker opens an interactive Python REPL with a bootstrapped Application, every Model from app/Models/*.py, and the core helpers — all automatically imported into the namespace.
$ python artisan.py tinker
Laraflask Tinker [Python 3.12.3]
14 variable(s) auto-imported. Type `dir()` to see them, `exit()` to quit.
>>> User.count()
42
>>> user = User.find(1)
>>> user.email
'rio@example.com'
>>> DB.select('SELECT COUNT(*) as total FROM posts')
[{'total': 128}]
>>> Cache.put('test', 'value', seconds=60)
>>> exit()Auto-imported into the namespace: every Model class in app/Models/, plus app (the Application instance), DB, Cache, Auth, Gate, Hash, Events, Queue, Storage, Schedule, Validator.
If IPython is installed, Tinker automatically uses it (autocomplete, syntax highlighting); otherwise, it falls back to Python's standard code.InteractiveConsole (with command history saved to ~/.laraflask_tinker_history).
See also Service Container for basic binding.
container.when(InvoiceController).needs(Logger).give(FileLogger)
container.when(PaymentController).needs(Logger).give(SentryLogger)container.tag([WeeklyReport, MonthlyReport], 'reports')
for report in container.tagged('reports'):
report.generate()A mixin that lets you add new methods to a class dynamically at runtime — inspired by Illuminate\Support\Traits\Macroable. QueryBuilder already inherits from Macroable by default.
from laraflask.orm.model import QueryBuilder
QueryBuilder.macro('whereActive', lambda self: self.where('active', True))
Post.query().whereActive().get() # the new method is immediately usablefrom laraflask.core.macroable import Macroable
class Money(Macroable):
def __init__(self, amount):
self.amount = amount
Money.macro('formatted', lambda self: f"${self.amount:,}")
Money(50000).formatted() # '$50,000'
Money.has_macro('formatted') # True
Money.mixin(SomeHelperClass) # register every public method at once
Money.flush_macros() # remove every macro belonging to this classMacros are isolated per subclass — registering a macro on one
Macroableclass will not "leak" into another class that also inherits fromMacroable.
from laraflask.core.exceptions import (
LaraflaskException, # base exception
ApplicationException,
ModelNotFoundException,
AuthorizationException,
AuthenticationException,
ValidationException,
HttpException,
NotFoundHttpException, # 404
UnauthorizedHttpException, # 401
ForbiddenHttpException, # 403
MethodNotAllowedHttpException, # 405
TooManyRequestsException, # 429
MaintenanceModeException, # 503
TokenMismatchException, # 419 (CSRF)
EncryptException,
QueueException,
CacheException,
StorageException,
NotificationException,
)# app/Exceptions/Handler.py
from laraflask.core.exceptions import ModelNotFoundException, ValidationException
from laraflask.api.api import ApiResponse
class Handler:
def render(self, request, exception):
if isinstance(exception, ModelNotFoundException):
return ApiResponse.not_found()
if isinstance(exception, ValidationException):
return ApiResponse.validation_error(exception.errors)
# ... default fallbackpip install laraflask-core[production] # gunicorn + geventAPP_ENV=production
APP_DEBUG=false
APP_KEY=<result of key:generate, NEVER leave this blank>
SESSION_SECURE_COOKIE=true# Run with Gunicorn (never use Flask's dev server in production)
gunicorn -w 4 -k gevent -b 0.0.0.0:8000 laraflask:flask_app-
APP_DEBUG=false -
APP_KEYis generated uniquely (python artisan.py key:generate), not left at a default value - Production database uses MySQL/PostgreSQL, not SQLite
-
SESSION_SECURE_COOKIE=trueif serving over HTTPS - Use
ForceHttpsMiddlewarebehind a reverse proxy that terminates TLS - Consider
PreventRequestForgeryMiddlewarefor an extra CSRF layer on critical endpoints - Install
cryptography&bcryptsoCrypt/Hashdon't fall back to their weaker substitute implementations
| # | Area | File(s) | Change |
|---|---|---|---|
| 1 | Artisan generators | console/artisan.py |
Implemented make:request, make:policy, make:resource, make:rule, make:provider, make:seeder, make:factory, make:observer, make:command — previously documented as not yet implemented. |
| 2 | make:policy / make:observer / make:factory |
console/artisan.py |
Accept --model to generate methods type-hinted against a specific model. |
| 3 | make:resource |
console/artisan.py |
Accepts --jsonapi to scaffold a JsonApiResource instead of the default ApiResource. |
| 4 | Seeder (new) |
orm/seeder.py |
Base class for database seeders, with call() to compose multiple seeders. |
| 5 | Factory (new) |
orm/factory.py |
Faker-backed base class for model factories: make(), create(), count(), state(). |
| 6 | Observer (new) |
orm/observer.py |
Base class for model observers with the standard lifecycle hooks. |
| 7 | Model.observe() |
orm/model.py |
Wires an Observer's hooks to the existing ModelCreating/ModelCreated/etc. events. Those events are still not auto-dispatched from save()/delete() — see Model Observers. |
| 8 | Documentation | README.md |
Added "Model Observers" and "Database Seeders & Factories" sections; updated the Artisan CLI command table; updated "Known Limitations". |
| # | Area | File(s) | Change |
|---|---|---|---|
| 1 | Import hygiene — routes | routes/web.py, routes/api.py |
Removed stale from flask import … top-level imports. Route is injected by the framework; responses use ApiResponse from core. |
| 2 | Import hygiene — Controller | app/Controllers/Controller.py |
Moved all from flask import … statements from module-level to lazy inline imports inside each method. respond() and error() now delegate to ApiResponse from core for a consistent response envelope. |
| 3 | Import hygiene — Handler | app/Exceptions/Handler.py |
Removed module-level from flask import …. All Flask primitives (jsonify, request, redirect, Response) are now imported lazily inside each method where they are used. |
| 4 | Import hygiene — Tests | tests/Unit/test_jsonapi.py, tests/Unit/test_prevent_request_forgery.py |
Removed top-level from flask import Flask. Extracted _make_flask_app() helper that defers the import to call-time; inline from flask import request/session kept inside middleware with test_request_context blocks where a live request context is required. |
| 5 | Documentation — README | README.md |
Fixed three code examples that showed top-level Flask imports in user-space files: middleware example now uses inline abort(); template controller example uses self.view() from the base Controller; Handler example uses ApiResponse.not_found() / ApiResponse.validation_error(). Version updated to v1.3.0. |
| 6 | Dependency versions | core/pyproject.toml, core/setup.py |
All optional and core dependency pins bumped to latest stable releases as of June 2026. |
| # | Area | File(s) | Change |
|---|---|---|---|
| 1 | Cache::touch() |
cache/cache.py |
Extend a key's TTL without re-fetching its value — a native single round-trip on Redis (EXPIRE), metadata-only update on File/Array/Database. |
| 2 | Queue::route() |
queue/queue.py |
Centrally register the default connection/queue per Job class. An explicit instance-level override still wins (backward compatible). |
| 3 | Interruptible |
queue/queue.py |
The interrupted(signal) mixin — the Worker catches SIGTERM/SIGINT and calls it before exiting. |
| 4 | JSON:API Resource | api/jsonapi.py (new) |
JsonApiResource/JsonApiResourceCollection per the JSON:API spec: {data, included, links, meta}, sparse fieldsets, relationship inclusion. |
| 5 | Model decorators | orm/model.py |
@table, @hidden, @fillable as an alternative to manually-declared class attributes — fully backward compatible. |
| 6 | Vector similarity search | orm/migration.py, orm/model.py |
Blueprint.vector() + QueryBuilder.order_by_similarity() for pgvector (PostgreSQL). |
| 7 | PreventRequestForgery |
security/security.py, middleware/middleware.py |
An origin-aware CSRF layer on top of regular token-based CSRF — opt-in, the old CsrfMiddleware is unchanged. |
| # | Feature | File(s) | Description |
|---|---|---|---|
| 1 | tinker |
console/artisan.py |
An interactive REPL with every Model & core helper auto-imported. |
| 2 | Collection |
core/collection.py (new) |
A chainable wrapper inspired by Illuminate\Support\Collection. as_collection=True is opt-in on get()/all(). |
| 3 | Contextual binding & tagging | core/container.py |
when().needs().give() and tag()/tagged() on the Service Container. |
| 4 | Macroable |
core/macroable.py (new) |
A mixin for adding methods dynamically at runtime. QueryBuilder inherits from this. |
| # | File | Bug | Fix |
|---|---|---|---|
| 1 | core/exceptions.py |
ValidationException/ModelNotFoundException were defined in 3 different places → isinstance() checks failed to match |
Single definition in core/exceptions.py, every module imports from there |
| 2 | orm/db.py |
SQLite was used with QueuePool + pool_size — SQLite doesn't support connection pooling |
StaticPool for :memory:, NullPool for file-based SQLite; QueuePool is used only for MySQL/PostgreSQL |
| 3 | orm/model.py |
session.query(Model).get(pk) — deprecated in SQLAlchemy 2.x |
Replaced with session.get(Model, pk) |
| 4 | orm/model.py |
first_or_create's .where() result wasn't re-chained |
Fixed: query = query.where(k, v) |
| 5 | orm/model.py |
ModelNotFoundException was duplicated, shadowing the version from core.exceptions |
Duplicate removed |
| 6 | orm/model.py |
Soft-delete filtering used the old _soft_delete attribute |
Fixed to use __soft_delete__ |
| 7 | validation/validator.py |
ValidationException was redefined locally |
Removed, now imported from core.exceptions |
| 8 | auth/auth.py |
Hash.needs_rehash() called bcrypt.checkpw() with empty bytes → ValueError |
Fixed |
| 9 | core/application.py |
importlib.util was used without being imported separately |
Added import importlib.util |
| 10 | scheduler/schedule.py |
class Event collided with events.dispatcher.Event |
Renamed to ScheduledEvent |
This section is documented honestly so it doesn't create false expectations:
belongs_to_many(many-to-many relationships through a pivot table) is not implemented — calling it raisesNotImplementedError.- Model lifecycle events (
ModelCreating,ModelCreated,ModelUpdating, etc.) are already defined as Event classes, andModel.observe()can wire anObserver's hooks to them — but the events themselves are still not automatically dispatched fromModel.save()/delete(). They need to be dispatched manually for an Observer to actually fire; see Model Observers for the workaround. - Job chaining & batching (
Bus.chain([...]).dispatch(),Bus.batch([...]).then().catch()) are not implemented. - Sanctum-style API Token Auth (
PersonalAccessToken,HasApiTokens, aTokenGuardbased on theAuthorization: Bearerheader) is not implemented. For stateless API authentication today, use the JWT Guard instead (see Authentication). Hash/Cryptfallback behavior: withoutbcrypt/cryptographyinstalled, both fall back to a simpler implementation (salted SHA-256 / XOR) that is not safe for production. Always installpip install laraflask-core[auth]in production environments.- HTTP status 419 (CSRF token mismatch) is used by
CsrfMiddleware/PreventRequestForgeryMiddlewareviaabort(419), but 419 isn't a standard HTTP code in Werkzeug — register a custom exception handler for this code at your application level if you don't already have one, or handle it viaapp.errorhandler.
Pull requests for any of the items above are very welcome.
MIT License.
Pull requests and issue reports are welcome. Please include unit tests for any new feature, and run python -m unittest discover -s app/tests/Unit before submitting.