teknoo/recipe
Teknoo Recipe is a lightweight PHP package for defining and running recipes as reusable, ordered steps. It helps you compose simple workflows and task sequences with a clean API, minimal dependencies, and testable code, making automation straightforward in PHP projects.
Install and check the baseline
composer require teknoo/recipe
The library requires PHP 8.4+ (it uses readonly properties, enums, attributes and Fibers throughout). There is no service provider, no facade, no facade config — it is a plain PHP object graph you wire yourself or through any PSR-11 / Symfony / Laravel container. That is the point of the package: you build the recipe, then hand it to a chef.
Read these in this order
demo/*.php — the fastest way to see a complete recipe, chef, workplan and promise wired together. Each demo is standalone.README.md — concepts and the cooking lifecycle.documentation/README.md — the reference. Spend the time on Bowl parameter resolution and Chef API; those two sections explain 80% of the surprises.src/ — the code is small and heavily typed; when in doubt the interfaces (RecipeInterface, ChefInterface, BowlInterface, IngredientInterface, PromiseInterface) are the real documentation.The mental model (memorise this mapping)
| Cooking term | Actual thing |
|---|---|
| Recipe | Immutable definition of an algorithm (a pipeline) |
| Ingredient | A requirement checked before cooking — not a value |
| Step | A middleware callable in the pipeline |
| Workplan | The associative array of data available while cooking |
| Bowl | Immutable per-step adapter that maps workplan → callable parameters |
| Chef | The executor holding state (Free → Trained → Cooking) |
| Dish | Result validator, called only when a step calls finish() |
| Promise | onSuccess / onFail pair — the #east way to get the result |
First working example
use Teknoo\Recipe\Chef;
use Teknoo\Recipe\ChefInterface;
use Teknoo\Recipe\Dish\DishClass;
use Teknoo\Recipe\Ingredient\Ingredient;
use Teknoo\Recipe\Promise\Promise;
use Teknoo\Recipe\Recipe;
$promise = new Promise(
static fn (Greeting $dish) => print($dish->message),
static fn (\Throwable $e) => fwrite(STDERR, $e->getMessage()),
);
$recipe = (new Recipe())
->require(new Ingredient('string', 'name'))
->cook(static function (string $name, ChefInterface $chef): void {
$chef->finish(new Greeting("Hello {$name}"));
})
->given(new DishClass(Greeting::class, $promise));
$chef = new Chef();
$chef = $chef->train($recipe); // stated: Free -> Trained
$chef->process(['name' => 'world']); // Trained -> Cooking -> Trained
Note the shape: nothing is returned from process(). The result is delivered to the promise attached via given(), and only if some step explicitly calls finish(). If a step just runs off the end of the pipeline, no dish is produced and the promise is never called — that is a silent no-op, not an error.
Best first use case
The package clicks fastest when you have an ordered pipeline whose steps are conditional and whose ordering may be tweaked per environment — an import/ETL chain, a document processing sequence, an order state machine, a request enrichment chain. Start by rewriting a 5–10 step foreach ($steps as $step) { $step($data); } pipeline as a recipe with require() for each precondition and one cook() per step. You gain: declarative preconditions, ordering by position, error handlers, sub-recipes, and dependency-injected configuration — while every step stays an ordinary callable you can unit test in isolation.
Always use the returned instance — the recipe is immutable
Every builder method (require, cook, execute, onError, given) returns a new Recipe. This is the single most common source of "my step never runs" and "my ingredient isn't checked":
// WRONG — the chain is discarded
$recipe->require(new Ingredient('string', 'name'));
// RIGHT
$recipe = $recipe->require(new Ingredient('string', 'name'));
The teknoo/immutable contract is real; treat the recipe as a value object and keep a small factory/Plan that produces it.
Prefer a Plan for anything reusable
A plan is a typed factory of recipes — a class you inject into a container, not a variable you pass around. This is the pattern that fits day-to-day Laravel/Symfony service code:
final class ImportPlan implements PlanInterface
{
public function __construct(private readonly ImportConfig $config) {}
public function populateRecipe(RecipeInterface $recipe): RecipeInterface
{
return $recipe
->require(new Ingredient('string', 'source'))
->cook($this->config->extractor, 10)
->cook($this->config->transformer, 20)
->cook($this->config->loader, 30)
->onError($this->config->errorHandler);
}
}
Plans also inject their own defaults into the workplan via addToWorkplan(), and the plan instance itself is available under the key PlanInterface::class — handy when a step wants to know which plan is running. An EditablePlanInterface accepts extra steps (add()) and error handlers after definition, which is how you let a module/plugin append to a shared pipeline without forking the plan.
Treat the workplan as a blackboard, and keep it flat
Steps communicate by writing to and reading from the workplan. Inside a step you either mutate the array by reference through the chef, or (cleaner) use continue():
$recipe = $recipe->cook(static function (string $raw, ChefInterface $chef): void {
$chef->continue(['parsed' => parse($raw)]); // adds to the workplan, moves on
});
continue($with, $nextStep) optionally jumps to a named step, which is how you implement branching without conditionals inside steps. Keep workplan keys simple and stable — they are the contract between steps, and they are also the fallback used for parameter injection.
Control order with explicit positions, use relative positions sparingly
Positions are integers; steps run by ascending position, then insertion order inside a position. Relative insertion is the feature that makes this package worth it for configurability:
use Teknoo\Recipe\RecipeRelativePositionEnum;
$recipe = $recipe
->cook($validate, 10)
->cook($enrich, 20)
->cook($audit, RecipeRelativePositionEnum::After, 'enrich')
->cook($cleanup, RecipeRelativePositionEnum::Before, 'enrich');
A relative step pointing at an unknown name is silently appended at the end — no exception. So if you let configuration drive relative positions, validate the anchor names yourself or you will get a step in a surprising place with no warning.
Use the $with mapping instead of renaming workplan keys
Rather than writing wrapper closures to adapt names, map them at cook() time:
$recipe->cook(
static fn (string $userName, string $template, string $literal) => render($userName, $template, $literal),
with: [
'userName' => 'name', // alias another workplan key
'template' => ['tpl', 'default_tpl'], // first key found wins
'literal' => new Value('fixed string'), // inject a constant
],
);
Value is the escape hatch for injecting configuration into steps without polluting the workplan.
Parameter resolution order is your injection contract
The bowl resolves each callable parameter in this order: reserved instances (ChefInterface, Fiber, CookingSupervisorInterface), the $with mapping, the workplan key equal to the parameter name, the workplan key equal to the parameter's class name, then any workplan value that is an instance of the parameter type, then _methodName, then the default value. Unresolvable → BadMethodCallException. Knowing this lets you rely on type-based injection (put the service object in the workplan and let the parameter type pull it in) rather than inventing key names.
Sub-recipes: execute() instead of one giant recipe
execute($subRecipeOrPlan) adds a sub-recipe as a step and starts a sub-chef with a copy of the workplan. This is the right way to compose: a top-level recipe orchestrates, each sub-recipe is independently testable and reusable. Use reserveAndBegin() from inside a step when you need to start a sub-chef manually and keep a handle on it.
Error handling: one handler per concern, not one global try/catch
onError() handlers receive the caught exception in the workplan under the key exception. A sub-chef propagating an error to its parent can be silenced with stopErrorReporting() — useful when a sub-recipe's failure is expected and handled locally. Register handlers per recipe/plan rather than globally so the handler knows the context. Remember the workplan is cleaned even when an exception escapes, so never rely on post-mortem workplan state in a global handler; capture what you need inside the error handler.
Fibers: the chef never drives the supervisor
If you use FiberBowl / FiberRecipeBowl, the chef registers fibers into a CookingSupervisor but does not schedule them. A step must explicitly call switch() (resume next), loop() (resume each once), finish() (loop until all terminated), or throw(). The standard pattern is one "join" step at the end of the pipeline:
$recipe = $recipe
->cook(fn () => $fetchA(), new FiberBowl()) // registered suspended
->cook(fn () => $fetchB(), new FiberBowl())
->cook(static function (CookingSupervisorInterface $supervisor): void {
$supervisor->finish(); // run them all to completion
}, 999);
Fibers are resumed in registration order; terminated fibers are forgotten. If you forget the join step, the fibers simply never run and the recipe completes "successfully" with nothing.
Consume results with promises, unwrap with fetchResult() at the boundary
Write recipe-adjacent code in #east style (success / fail callbacks), and only call fetchResult() / fetchResultIfCalled() at the edge — a controller, a console command — where you need a return value. next() chains promises and returns a new one; WrappedOneCalledPromise is what you actually get back. FiberPromise runs the callback in a fiber when the callback itself is heavy.
Mergeable and Transformable for domain values
If a workplan value implements MergeableInterface, $chef->merge() combines duplicate inputs instead of overwriting. If it implements TransformableInterface, #[Transform(SomeClass::class)] on a step parameter transforms the ingredient before injection — so a step can declare MyDto $dto while the workplan holds a raw entity. This keeps transformation out of the step bodies.
Recommended wiring in an application
Plan class per business pipeline, constructor-injected with strategy callables (extractors, loaders, validators).RecipeRunner) that takes a plan, builds a chef, trains it, attaches a promise, calls process(), and converts the promise into a return value / HTTP response. Everything else in the app talks to RecipeRunner, not to Chef.null means "absent", not "null"
A workplan entry with value null is treated as missing by ingredient checking and by parameter resolution. If a legitimate domain value is null, wrap it (new Value(null) in a $with mapping, or a nullable DTO). This bites people coming from "just pass null around" code and produces mystifying BadMethodCallExceptions.
train() compiles a clone — your instance stays a Draft
$chef = $chef->train($recipe) returns a new, Trained chef; the original is still Free. Calling process() on an untrained chef is an error. Likewise the recipe you hold stays Draft after train() — you cannot detect "compiled" on it. Always keep the returned chef.
Chef reuse and cloning
A chef can process() multiple workplans sequentially and can be cloned, but each process() runs the full pipeline and cleans the workplan afterwards. Do not stash mutable services in the workplan expecting them to be re-created per cooking — they are not. Prefer one chef per logical run unless you have measured a reason to reuse.
The workplan is cleaned on the way out — including on exception
Anything a step writes is gone after process() returns. If you need a trace, use error handlers and promise callbacks, or write to a collector service you injected via the workplan.
Duplicate step names are silently suffixed
Two cook() calls with the same name produce step and step1. That means a RecipeRelativePositionEnum::After, 'step' anchor refers to the first one. Name steps deliberately when you plan to anchor on them, and do not derive names from user input.
Relative positions pointing at unknown steps fail silently
Already mentioned, but worth repeating because it is the number-one "why is my step running last?" bug. If positions come from configuration, validate anchors at boot.
BadMethodCallException from a step means an unresolved parameter
Almost always one of: typo in the key, the key is null, the workplan value does not satisfy the parameter's type, or a $with mapping points at a key that was not produced. Add a default value (?string $x = null) while developing to see which parameter is the offender; the exception message generally names the callable and parameter.
Parameter name shadowing
Because workplan keys are tried by parameter name before the class name, a workplan key accidentally named like a parameter ($config, $user, $request) will be injected into a step that expected type-based resolution. Keep workplan keys namespaced or prefixed (import.source, import.config) if you have many services on the workplan.
Object ingredients accept class names as strings — never feed them user input
An object ingredient is matched with is_a(), and a string in the workplan can satisfy a class-typed ingredient. This is a feature for fixtures and a security hazard for request data. Sanitise, or do not put user-controlled strings into a workplan at all.
DynamicBowl / DynamicFiberBowl execute a callable read from the workplan
That is arbitrary code execution by design. Never map a dynamic bowl's key to anything a request can influence. If you need dynamic dispatch, restrict the workplan value to a whitelist-derived callable set at the boundary.
Fibers require PHP ≥ 8.4 and a supervising step
Beyond "forgot to call finish()/loop()": a fiber that throws must be resumed via $supervisor->throw() to be observed; otherwise errors surface late or not at all. Also, fibers are resumed in registration order, not completion order — do not assume switch() gives you round-robin fairness on I/O-bound work.
Promises are call-once by default
The second success()/fail() on the same promise is a no-op (or worse, silently ignored) unless you call allowReuse(). If you are debugging "why didn't my promise fire twice", that is why. Use next() to build chains; each link is its own one-shot.
No dish unless someone asks for it
If no step calls finish(), the recipe completes with no result and no error. Make "the last step calls finish()" an explicit convention, and add an integration test that asserts the dish value — not merely that no exception was thrown. (The project's own testing rules say exactly this: assert the final result.)
interruptCooking() is not an error
It stops the pipeline without validating a dish. Useful for short-circuit "nothing to do" paths; make sure the caller can distinguish it from a successful dish — the promise will not have fired.
Debugging checklist
process() actually run a chef returned from train()?null value of the expected type?finish() actually execute (check positions and any continue() jumps)?fetchResultIfCalled() is your friend.)Ecosystem risk
Three stars, zero dependents, a single maintainer, BSD-3-Clause. The code is well-engineered — 100% coverage, PHPStan level max, Behat + PHPUnit, PSR-12, immutability contracts — but it is a small, opinionated library with a niche vocabulary. Budget for reading src/ rather than hoping for Stack Overflow answers, pin the version, and wrap it behind your own RecipeRunner / Plan boundary so you can replace it with a hand-rolled pipeline if the maintainer walks away. The concepts (immutable recipe, workplan, middleware steps, promises) are simple enough to reimplement in a few hundred lines if it ever comes to that.
Extension points worth knowing
BowlInterface implementations if you need bespoke parameter resolution or execution (e.g. queued steps).EditablePlanInterface to let modules append steps/handlers to a shared plan.TransformableInterface + #[Transform] to keep DTO construction out of steps.MergeableInterface for accumulating values across steps (counters, audit trails).CookingSupervisorInterface if the built-in fiber scheduling policy is not what you need.How can I help you explore Laravel packages today?