aminsamadzadeh/simorgh
Simorgh is a Laravel package that provides a flexible, developer-friendly toolkit for building PHP applications. It offers reusable components and simple configuration to reduce boilerplate, streamline common backend tasks, and help teams ship maintainable Laravel projects faster.
Install and sanity-check compatibility first. The package targets the Laravel 5/6/7 era (last release Nov 2020), so before running composer require aminsamadzadeh/simorgh, open its composer.json on GitHub and check the illuminate/* and php constraints. On Laravel 9+ you will typically need a fork or a "replace"/platform-config workaround:
"config": { "platform": { "php": "7.4.0" } }
Read the source before the README. With ~11 stars the entire package is a handful of files — realistically a service provider, a config file, and one trait or query-builder class. That trait is the public API: it will tell you the exact query-parameter names it recognizes (commonly filter, sort, include/with, search, per_page, page) and how it parses them. Fifteen minutes there beats an hour of guessing.
First use case: a list endpoint. The idiomatic flow is trait-on-model, then hand the request to it in the controller:
// app/Models/Post.php
use AminSamadzadeh\Simorgh\Traits\Filterable; // verify exact namespace in src/
class Post extends Model
{
use Filterable;
protected $filterable = ['title', 'status', 'created_at'];
protected $sortable = ['title', 'created_at'];
protected $searchable = ['title', 'body'];
}
// app/Http/Controllers/Api/PostController.php
public function index(Request $request)
{
return PostResource::collection(Post::query()->filter($request)->paginate());
}
Start with one model and one endpoint, confirm the SQL it produces, and only then roll it out — this package is a convenience layer over Builder, and convenience layers are exactly where surprising SQL hides.
Allowlist per model, not per controller. The most maintainable pattern is declaring $filterable, $sortable, $searchable, and $includeable (relation names) as model properties. It keeps the contract next to the schema, so a column rename forces you to update the allowlist instead of silently leaking the new column.
Validate the query string like any other input. Put query-param rules in a FormRequest so per_page, sort, and filter keys are constrained before they reach the builder:
class PostIndexRequest extends FormRequest
{
public function rules(): array
{
return [
'filter.status' => ['nullable', 'in:draft,published'],
'sort' => ['nullable', 'string', 'max:64'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
}
Cap pagination. Always override per_page server-side (min($request->per_page ?? 15, 100)) — an unbounded ?per_page=100000 is a trivial DoS on any list endpoint.
Compose with scopes, don't fight them. Apply authorization and tenancy scopes before handing off to the package:
Post::query()
->where('tenant_id', $request->user()->tenant_id) // non-negotiable, not user-controlled
->filter($request) // cosmetic/user-controlled
->paginate($perPage);
Anything security-relevant belongs in a scope or policy; anything the client can toggle belongs in the package. Never let a filter param reach a column that determines visibility.
Eager-load deliberately. If the package supports an include/with param, allowlist relations and validate them against a fixed array — arbitrary relation loading is both an N+1 generator and an information-disclosure vector. Pair it with ->withCount() for list badges so you don't lazy-load inside resources.
Wrap it in a reusable base controller or a dedicated QueryFilters object if you find yourself repeating the same three lines across twenty controllers. A small App\Support\IndexQuery that calls the package and applies your pagination default keeps the dependency swappable — which matters a lot for a package that hasn't shipped since 2020.
Test the contract, not the internals. Feature tests hitting /api/posts?filter[status]=draft&sort=-created_at and asserting the JSON shape plus assertDatabaseCount give you a regression net that survives replacing the package entirely.
Maintenance risk is the headline gotcha. Last release 2020, 11 stars, single maintainer. Treat it as vendored code you own: pin the exact version in composer.json, and if you depend on it in production, budget an afternoon to copy the trait into app/ and drop the dependency. That is often cheaper than fighting constraints on every Laravel upgrade.
Unvalidated params → SQL surface. If the package interpolates column names or sort directions from the request, an empty allowlist (or a forgotten one) can mean ?sort=password or worse. Grep the source for orderBy, whereRaw, DB::raw, and any ->{$column} dynamic call. If you find a raw path with no allowlist check, add one at the call site.
Direction handling. -created_at vs created_at desc vs sort=created_at,direction=desc — conventions differ and the package may support only one. Confirm by reading the parser, then document the convention in your API docs so front-end developers aren't guessing.
Operator support is inconsistent across packages. filter[created_at][gte]=2024-01-01 may or may not map to >=. Test each operator you plan to expose (gte, lte, like, in) with a real request and read the generated SQL — silence here usually means the operator is ignored, not that it's working.
Parameter naming collisions with Laravel. page is consumed by paginate(), per_page is not a framework concept, and array-style params (filter[status]) arrive as nested arrays — make sure your FormRequest rules use dot notation or filter.*.
Debugging recipe. When results look wrong, isolate package vs. your code:
$q = Post::query()->filter($request);
dump($q->toSql(), $q->getBindings());
If toSql() looks right but JSON is wrong, the problem is in your Resource or the pagination call, not the filter. Add a temporary DB::listen(fn ($q) => logger($q->sql)) in a service provider when you need the full request lifecycle.
Don't call get() before the builder finishes. Order matters: scope → package filter → package sort → paginate()/cursorPaginate(). Calling ->get() in the middle silently loses everything applied afterward.
Extension points to check. Whether the trait methods are protected and overridable determines how much you can customize without forking. If they're private or the config isn't publishable, assume zero extension surface and wrap rather than inherit.
Have an exit plan. spatie/laravel-query-builder covers the same ground with active maintenance, documented allowlists, and Laravel 11 support. If you're starting a new project today, evaluate it side-by-side; adopt Simorgh only if you specifically want its smaller footprint or already run an older Laravel version where it's proven.
How can I help you explore Laravel packages today?