Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Recipe Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. demo/*.php — the fastest way to see a complete recipe, chef, workplan and promise wired together. Each demo is standalone.
  2. README.md — concepts and the cooking lifecycle.
  3. documentation/README.md — the reference. Spend the time on Bowl parameter resolution and Chef API; those two sections explain 80% of the surprises.
  4. 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.

Implementation Patterns

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

  • One Plan class per business pipeline, constructor-injected with strategy callables (extractors, loaders, validators).
  • A thin service (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.
  • Keep chefs short-lived per request unless you have a good reason; a trained chef is reusable and cloneable, but sharing state across cookings is an invitation to confusing bugs.

Gotchas and Tips

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

  1. Did every builder call get reassigned?
  2. Did process() actually run a chef returned from train()?
  3. Does the workplan contain the key with a non-null value of the expected type?
  4. Does the step that calls finish() actually execute (check positions and any continue() jumps)?
  5. If fibers are involved, is the supervisor step present and called?
  6. Was the promise called at all? (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

  • Custom 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).
  • A custom CookingSupervisorInterface if the built-in fiber scheduling policy is not what you need.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package