serri / alchemist
The JSON Revolution for Laravel, a simple, fast, and elegant alternative to Laravel JSON Resource.
Requires
- php: ^8.2
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/pagination: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
This package is auto-updated.
Last update: 2026-07-07 18:57:27 UTC
README
The JSON Revolution for Laravel, a simple, fast, and elegant alternative to Laravel JSON Resource.
📖 Table of Contents
- Philosophy
- Requirements
- Installation
- Fundamentals
- Quick Start
- Usage & Examples
- Custom Ingredients
- Error Handling
- Testing
- Changelog
- License
🔮 Philosophy - The Problem with Traditional Laravel Resources
We've all been there:
- Creating endless resource classes that mostly repeat the same boilerplate.
- Duplicating code across multiple API responses.
- Drowning in maintenance when frontend requirements change.
- Wrestling with nested relationships that bloat your codebase.
The breaking point comes when:
- Your API evolves and resources multiply.
- Frontend devs request constant field changes.
- Your models grow, but your resource classes don't scale.
- Nested relationships turn into unmaintainable spaghetti.
The Solution: Laravel Alchemist - Formula Approach
One File to Rule Them All
Each model gets a single SomeModelFormula.php where you:
✅ Define all fields as simple strings in arrays.
✅ Manage every API variation in one place.
✅ Update database changes with a single edit.
Relationship Handling Made Simple
- Reference nested resources by their name only.
- Each relation maintains its own
Formula::class. - No more recursive resource nightmares.
Frontend-Friendly Flexibility
- Instantly modify fields without resource class hopping.
- Track all API variations through clear formula methods.
- Never miss a field update again.
Why This Works
- Less Code: Eliminates 80%+ of resource boilerplate.
- True Maintainability: All changes flow through controlled formulas.
- Team Friendly: Frontend can request changes without breaking your flow.
“Laravel Resources grant you the illusion of control – meticulous yet maddening. Laravel Alchemist surrenders this false dominion... and in its place conjures true magic.„
📋 Requirements
| Laravel | PHP | Alchemist |
|---|---|---|
| 12.x | ≥ 8.2 | ≥ 1.1 |
| 13.x | ≥ 8.3 | ≥ 1.1 |
Laravel 11 is supported by Alchemist 1.0.x only: it reached end of security support in March 2026 and is no longer maintained.
🔧 Installation
You may install Alchemist using the Composer package manager:
composer require serri/alchemist
You can publish the Alchemist configuration file config/alchemist.php and the default Formulas/Formula.php using
the vendor:publish Artisan command:
php artisan vendor:publish --provider="Serri\Alchemist\Providers\AlchemistServiceProvider"
Or for the configuration file only:
php artisan vendor:publish --tag=alchemist-config
For the default formula class only:
php artisan vendor:publish --tag=alchemist-formula
📖 Fundamentals
To wield this package's magic effectively, you must understand these arcane principles:
The Formulas Directory
- Your sacred workshop where all model formulas reside
- Created automatically at
app/Formulas/Formula.phpwhen you publish the default formula class as we did in the Installation section:
namespace App\Formulas; class Formula { const BlankParchment = ['id']; # Default formula. }
Crafting Your Formulas
Summon a formula class with the generator:
php artisan make:formula PostFormula # Or scan a model: pre-fills BlankParchment with its exposed fields and # lists its #[Mutagen] / #[Relation] methods as hints. php artisan make:formula PostFormula --model=Post
The generator writes to your configured formulas_folder_path (default app/Formulas/), creates the base
Formula class if it is missing, and refuses to overwrite an existing formula unless you pass --force.
Linting Your Formulas
Formulas are strings resolved at runtime — so let CI catch the typos before production does:
php artisan formula:lint
Every constant of every formula class is validated against its model using the exact same discovery the brew
pipeline uses, including nested specs (validated against the related model, no database needed). Unknown
fields fail with a "did you mean" suggestion; the command exits non-zero, so add it next to your test step in CI.
Models are matched by convention (PostFormula → App\Models\Post) or explicitly:
class WeirdlyNamedFormula extends Formula { protected static string $model = \App\Models\Post::class; }
Use --json for machine-readable output.
Or craft one by hand in app/Formulas/ like so:
namespace App\Formulas; class UserFormula extends Formula { # Define your transformations here. # ex: const UserLogin = ['id', 'username', /*...etc.*/]; // ... other formulas. }
Key Laws:
Modular Codebases
Fallback resolution searches App\Formulas by convention. Modular / DDD apps can add their own namespaces
(searched in order — the first entry is also where make:formula generates classes):
// config/alchemist.php 'formula_namespaces' => [ 'Modules\\Blog\\Formulas', 'App\\Formulas', ],
Using the package's default Formula
If you did not publish app/Formulas/Formula.php, you can still extend the default Formula provided by the package
like this:
namespace App\Formulas; use Serri\Alchemist\Formulas\Formula; class UserFormula extends Formula { // Define your transformations here. }
🪄 Quick Start
1. Model Configuration
To enable formula support, models must use the HasAlchemyFormulas concern.
use Serri\Alchemist\Concerns\HasAlchemyFormulas; use Illuminate\Database\Eloquent\Model; class Post extends Model { use HasAlchemyFormulas; // }
2. Exposing Fields
By default, everything included in the $fillable array and the $guarded array is automatically available to formulas.
use Serri\Alchemist\Concerns\HasAlchemyFormulas; use Illuminate\Database\Eloquent\Model; class Post extends Model { use HasAlchemyFormulas; # Automatically exposed to formulas. protected $guarded = ['id']; # Automatically exposed to formulas. protected $fillable = [ 'title', 'description', 'published_at', ]; }
Note: Eloquent's default
$guarded = ['*']wildcard is never treated as a field. If you rely on the default, expose your columns through$fillable.
$hiddenis respected: fields in a model's$hiddenproperty are never exposed to formulas — matching Eloquent's serialisation contract. A formula referencing one throws with a clear message. Deliberately brewing hidden attributes requires settingalchemist.respect_hiddentofalse.
3. Exposing Relationships
Relationships must be explicitly marked with the #[Relation] decorator to be available in formulas:
use Serri\Alchemist\Decorators\Relation; #[Relation] # Exposed to formulas as 'comments' public function comments(): HasMany { return $this->hasMany(Comment::class); } #[Relation(name: 'author_profile')] # Exposed to formulas as 'author_profile' public function profile(): HasOne { return $this->hasOne(Profile::class); }
4. Exposing Custom Methods
Model methods require the #[Mutagen] decorator to be accessible in formulas:
use Serri\Alchemist\Decorators\Mutagen; #[Mutagen] # Exposed to formulas as 'fullName' public function fullName(): string { return "{$this->first_name} {$this->last_name}"; } #[Mutagen(name: 'is_verified')] # Exposed to formulas as 'is_verified' public function isVerified(): bool { return $this->email_verified_at !== null; }
Keynotes
5. Crafting Formulas
Once your models are properly configured, you can define formulas to transform your data. Formulas are defined in
classes within the app/Formulas/ directory.
Here is an example:
namespace App\Formulas; class PostFormula extends Formula { const Author = ['id', 'title', 'author_profile']; const WithComments = ['id', 'title', 'comments']; const Detailed = ['id', 'title', 'description', 'comments', 'author_profile']; }
For the profile formula:
namespace App\Formulas; class ProfileFormula extends Formula { const OnlyName = ['fullName']; const AnyOther = ['id', 'username', 'fullName']; }
🛠️ Usage & Examples
Basic Data Transformation
Pass a formula straight into the brew — no global state, no sequencing:
use App\Models\Post; use App\Formulas\PostFormula; use Serri\Alchemist\Facades\Alchemist; // Eager-load the relations your formula uses! $posts = Post::with('author.profile')->get(); $transformedData = Alchemist::brew($posts, PostFormula::Author);
...where the formula pins the whole response shape, nested relations included:
class PostFormula extends Formula { const Author = [ 'id', 'title', 'author_profile' => ProfileFormula::OnlyName, # Nested spec: shapes the relation inline. ]; }
Nested specs recurse to any depth, and any relation listed as a plain string keeps resolving through the related model's own formula.
The classic stateful style remains fully supported — set formulas up front, brew later:
Post::setFormula(PostFormula::Author); Profile::setFormula(ProfileFormula::OnlyName); $transformedData = Alchemist::brew($posts);
A per-call formula always wins over setFormula() for that call, so the two styles mix safely.
Results:
[
[
'id' => 1,
'title' => "Post 1",
'author_profile' => [
'fullName' => "some author name"
]
],
[
'id' => 2,
'title' => 'Post 2',
'author_profile' => [
'fullName' => "some author name"
]
],
[
'id' => 3,
'title' => 'Post 3',
'author_profile' => [
'fullName' => "some author name"
]
]
]
Eager loading: brewing a
#[Relation]field accesses the relation on each model. Always eager-load (Post::with('comments')) the relations your formula references, or you will trigger N+1 queries.
Key Methods
| Method | Purpose | Example |
|---|---|---|
brew() |
Transforms a model or collection into an array | Alchemist::brew($posts, PostFormula::Author) |
brewBatch() |
Transforms a paginator's items, keeping its metadata | Alchemist::brewBatch($paginator, $formula) |
brewMixed() |
Brews a mixed-class collection, per-class formulas | Alchemist::brewMixed($feed, $formulaMap) |
response() |
Brews anything straight into a JsonResponse |
Alchemist::response($paginator, $formula) |
setFormula() |
Assigns the model's active (fallback) formula | Post::setFormula(PostFormula::DetailedView) |
unsetFormula() |
Clears it, falling back to BlankParchment |
Post::unsetFormula() |
The $formula argument is optional everywhere: omit it and the model's active formula (or BlankParchment) applies.
Laravel Octane: formulas set via
setFormula()are flushed automatically at every request boundary, so state can never leak between requests in long-lived workers. Per-call formulas never touch shared state at all.
Patterns
1. Context-Aware Formulas
$formula = auth()->user()->isAdmin() ? PostFormula::AdminView : PostFormula::PublicView; Post::setFormula($formula);
2. Direct Model Transformation
$post = Post::find(1); return Alchemist::brew($post); // Auto-detects single model
3. Pagination Support
$paginated = Post::paginate(15); return Alchemist::brewBatch($paginated); // Preserves pagination structure
brewBatch() accepts length-aware, simple (simplePaginate), and cursor (cursorPaginate) paginators alike.
4. Mixed Collections
Feeds, search results, and morph queries mix model classes — brewMixed() brews each element with its own
class's formula, order preserved:
$feed = collect([$post, $comment, $anotherPost]); $brewed = Alchemist::brewMixed($feed, [ Post::class => PostFormula::Summary, Comment::class => CommentFormula::BodyOnly, ]); // Classes omitted from the map fall back to their active formula / BlankParchment.
Plain brew() requires a homogeneous collection and now fails fast (pointing at brewMixed()) when given a
mixed one.
5. One-Line Controllers
response() brews anything — model, collection, or paginator — straight into a JsonResponse:
public function index() { return Alchemist::response( Post::with('comments')->paginate(15), PostFormula::WithComments, ); } // Optional status and headers: return Alchemist::response($post, PostFormula::Detailed, 201, ['X-Custom' => 'header']);
Paginators keep their pagination envelope (data, total, links, ...); everything else returns the brewed array.
6. Sparse Fieldsets from the Request
Let clients narrow the response — with your formula as the ceiling they can never exceed:
use Serri\Alchemist\Support\Sieve; // GET /posts?fields=id,title&include=comments&fields[comments]=body public function index(Request $request) { return Alchemist::response( Post::with('comments')->paginate(15), Sieve::from($request, PostFormula::Detailed), // Detailed is the allow-list ); }
fields=id,titlenarrows top-level fields (fields[self]=...when combined with relation fields).include=comments,comments.postchooses which nested-spec relations survive (dot paths for depth).fields[comments]=bodynarrows a relation's nested spec, at any depth (fields[comments.post]=id).- No parameters → the allow-list verbatim, so it is a drop-in wrapper.
- Requests outside the allow-list are silently dropped; use
Sieve::strict()to throw anInvalidSieveRequestExceptioninstead (map it to a 4xx in your exception handler).
Syntax Variations
1. Helper Function (Simplest)
$posts = Post::all(); $transformed = alchemist()->brew($posts);
2. Facade (For static contexts)
use Serri\Alchemist\Facades\Alchemist; $data = Alchemist::brew($models);
3. Dependency Injection (Recommended for controllers)
use Serri\Alchemist\Services\Alchemist; class PostController { public function __construct( protected Alchemist $alchemist, ) {} public function index() { return $this->alchemist->brew(Post::all()); } }
All three resolve the same container singleton.
🧪 Custom Ingredients
Ingredients are the strategies the Alchemist uses to resolve each formula field. The built-ins cover $fillable,
$guarded, #[Mutagen], and #[Relation] — but you can brew your own.
An ingredient implements Serri\Alchemist\Contracts\IngredientContract:
namespace App\Ingredients; use Serri\Alchemist\Contracts\IngredientContract; final class UppercaseIngredient implements IngredientContract { /** * The model property that lists the fields this ingredient resolves. */ public static function ingredientName(): string { return 'shoutable'; } /** * Resolve one field on one model. */ public static function infuse(string $ingredient, mixed $brewing): array { return [ $ingredient => strtoupper($brewing[$ingredient]), ]; } }
Register it in config/alchemist.php:
'ingredients' => [ \Serri\Alchemist\Ingredients\FillableIngredient::class, \Serri\Alchemist\Ingredients\GuardedIngredient::class, \Serri\Alchemist\Ingredients\MutagenIngredient::class, \Serri\Alchemist\Ingredients\RelationIngredient::class, \App\Ingredients\UppercaseIngredient::class, ],
Then list the fields on your model:
class Post extends Model { use HasAlchemyFormulas; protected array $shoutable = ['title']; }
Rules of the craft:
- Order matters: when two ingredients expose the same field name, the one registered later wins.
- Models without the ingredient's property simply contribute no fields for it — no error.
- Decorator-driven ingredients (like the built-in Mutagen/Relation) additionally implement a static
usesDecorator(): boolreturningtrue, andingredientName()returns the attribute class to scan for.
🚨 Error Handling
Everything the Alchemist throws extends Serri\Alchemist\Exceptions\AlchemistException, so one catch covers all:
| Exception | Thrown when |
|---|---|
UnknownFormulaFieldException |
A formula references a field the model does not expose (typo, or forgotten decorator). |
UnbrewableInputException |
The input collection contains non-models, or the model lacks the HasAlchemyFormulas trait. |
InvalidConfigurationException |
The alchemist config is missing or malformed. |
InvalidIngredientException |
A configured ingredient class does not exist or lacks infuse(). |
🧬 Testing
composer test # PHPUnit (unit + feature suites) composer lint # Pint code style check composer analyse # PHPStan level 6
The CI matrix runs the suite on PHP 8.2–8.4 across Laravel 12 and 13.
📆 Changelog
See CHANGELOG.md for release history and upgrade notes.
📜 License
This project is open-source and available under the MIT License.