Repository navigation
Releases: marcreichel/laya-php
Release list
1.4.0
Decisions move further into Laravel: a Decides validation rule checks input against a decision, and the AsDecision cast stores a decision object in a JSON column. Decision classes take float parameters, decideWithResult() keeps the raw probabilities next to the decision, and laya:eval measures a decision class against labelled examples.
// Validation
$request->validate([
'body' => ['required', 'string', Decides::that(Moderation::class, fn (Moderation $m) => ! $m->abusive && ! $m->spam)],
'comment' => [Decides::not('Does the text contain insults, hate speech or threats?')],
]);
// Eloquent
protected function casts(): array
{
return ['triage' => AsDecision::of(Triage::class)];
}
// The decision and the Result it came from
$decision = $laya->decideWithResult($ticket, Triage::class);
$decision->value; // Triage
$decision->result->choice('department')->probabilities;New
Decidesvalidation rule.Decides::that(Class::class, fn ($decision) => ...),Decides::yes('question')andDecides::not('question'), with->message()for your own wording. When laya-serve is unreachable or busy, validation fails unless you call->passWhenUnavailable(). Other errors are thrown. The defaultvalidation.layamessage ships in 12 languages under thelayanamespace, and an app's ownvalidation.layawins.AsDecisionEloquent cast.AsDecision::of(Triage::class)orAsDecision::class.':'.Triage::classstores a decision object in a JSON column. Enums are stored by backing value and read back through the constructor, so readonly classes and private promoted properties work. Lists (#[Of]), floats,#[Scale]enums andnullround-trip, and anullcolumn is anullattribute. Assigning anything other than an instance of the class, or reading JSON with a missing key, a wrong type or an unknown enum value, throws the newInvalidDecisionCastException, which names the model attribute.floatparameters in decision classes. Afloatwith#[Levels]becomes a score question and gets the expected level (ScoreAnswer::$score). Afloatwithout#[Levels]becomes a yes/no question and gets P(yes). You can now sort and rank withdecide()instead of dropping down topredict().thresholdon a float throwsInvalidQuestionException.Laya::fake()accepts a fractional expected level and splits it between the two neighbouring levels (1.3 → 70% level 1, 30% level 2).decideWithResult()anddecideManyWithResult(). They return aDecisionwith the hydrated$valueand the raw$result, so probabilities, confidences andtruncatedare available without asking the questions twice. The batch variant is@experimentallikedecideMany()and keeps the keys of$states.laya:evalandvendor/bin/laya-eval. Score a decision class against labelled rows from CSV or JSON Lines. The report covers accuracy, a confusion matrix (choice, score) or precision and recall (yes/no), calibration peranswerConfidencebucket, the lowestminConfidencethat reaches--precisionwith the share sent to a human, and the best-F1thresholdfor yes/no.--jsonprints it as JSON.vendor/bin/laya-evalruns without Laravel and reads--url/--api-keyorLAYA_URL/LAYA_API_KEY. TheEvaluationclass gives you the same report in PHP. Seeexamples/08-evaluation.php.Laya::questions(). It returns the questionsdecide()would send for a class and throws the sameInvalidQuestionExceptionfor a broken one, so a one-line test checks a decision class without a server or a fake.- Versioned cache keys. With
versionCache(LAYA_CACHE_VERSIONEDin Laravel, off by default), cache keys include laya-serve's checkpoint revisions, so cached answers from an old checkpoint aren't served after an upgrade./healthis called once per instance, and the revision hash is cached for 60 s. If/healthfails, the plain key is used and the prediction still goes through. - The full usage report on
Result.stateTokens,stateTokensDropped,truncatedQuestionsandcollapsedOptions(aCollapsedOptionsper question id withtotal,distinctandtokensPerOption). Servers that don't send them leave the fieldsnullor empty.laya:trynames the cut-off questions and the dropped tokens in its truncation warning, and its--jsonoutput includes the whole report. - HTTP client settings in Laravel.
LAYA_TIMEOUTandLAYA_RETRIESbuild asymfony/http-clientclient that retries on 503 and transport errors with backoff. To use your own client, setlaya.http_clientto a service id or bindPsr\Http\Client\ClientInterface; it takes precedence over both settings. Setting either one withoutsymfony/http-clientinstalled throws an exception that says what to install. - Configurable batch size. Pass
batchSize:tonew Laya()(Laravel:batch_size, fromLAYA_BATCH_SIZE). The default stays 64, and a size below 1 throwsInvalidOptionException. - Server limits in
Laya::fake(). The fake now rejects what laya-serve rejects: too many questions, choice options, score levels or total options, too long a state, and too large a batch, with laya-serve's wording. Passlimits:(MarcReichel\Laya\Testing\Limits) to change them, orLimits::none()to turn them off.
Improved
- Server errors from
decide()anddecideMany()name the decision class, e.g.Triage: laya-serve: too many questions (65 > 64) (HTTP 413).
Upgrading
There are no breaking API changes, but two things behave differently:
Laya::fake()enforces laya-serve's limits by default. A test that fakes more than 64 questions, an oversized batch or a very long state now throws aValidationException, just as the real server would answer. Passlimits: Limits::none()to get the old behaviour.ValidationExceptionmessages fromdecide()anddecideMany()start with the class name. Update any test that matches the exact message.
compose.yaml now builds Laya v0.4.6 (c73908d) instead of v0.4.0. The 0.4.1–0.4.6 changes are additive and need no SDK changes.
1.3.0
Decision classes get two new shapes: an enum can be a score scale, and an array of enum cases asks one yes/no question per case. laya-serve can now abstain on low-confidence answers itself, predictions dispatch PSR-14 events, and php artisan laya:try lets you test a decision class on a piece of text without writing code.
final class Triage
{
public function __construct(
#[Ask('How urgent is this?')]
public Urgency $urgency, // #[Scale] enum: Low, Normal, Blocking
#[Ask('Does the message mention {case}?')]
#[Of(Topic::class)]
public array $topics, // [Topic::Billing, Topic::Account]
) {}
}
$triage = $laya->decide($ticket, Triage::class, minConfidence: 0.7);New
#[Of(Enum::class)]on array parameters. laya asks one yes/no question per case and fills{case}in the question with the case's#[Describe]text or its value. You get the cases with P(yes) ≥ 0.5 (or yourthreshold), in declaration order. The questions have ids liketopics.billing. A class that needs more than 64 questions throws anInvalidQuestionException.#[Scale]enums. A backed enum marked#[Scale]becomes a score question. Its cases are the levels, lowest first, and you get back the case at the most likely level instead of an index. A backed enum without#[Scale]stays a choice question.- Server-side abstention.
predict(),predictMany(),decide()anddecideMany()takeminConfidence. Pass one threshold from 0 to 1, or a map with a threshold per option-count bucket (['choice:3-5' => 0.6, 'noul:2' => 0.8, 'default' => 0.7]). Answers report what the gate decided inabstention(the newAbstentionenum),abstentionThresholdandlowConfidence. A malformed value throws the newInvalidOptionExceptionbefore anything is sent. - PSR-14 events. Pass a dispatcher as
events:to getPredictionMadeafter each prediction (cache hits included),PredictionFailedright before an exception is thrown, andCacheFailedwhen the cache misbehaves. States are left out of the events unless you setincludeState. In Laravel, the service provider wires up the app's dispatcher, so listeners andEvent::fake()work without any setup. Turn events off withLAYA_EVENTS=false, or include states withLAYA_EVENTS_INCLUDE_STATE=true. php artisan laya:try. It runs a decision class on text from an argument,--fileor STDIN. For each parameter it prints the question, the hydrated value, the answer confidence and a bar for every option's probability. It also shows the routed checkpoint and warns when the text was cut off. It takes--model,--max-len,--head-max-lenand--json, and resolves short class names underApp\.- Idle unload in
laya:health.HealthStatusexposesidleUnloadSecondsandidleSeconds, andlaya:healthprints them. When laya-serve has unloaded its checkpoints after a quiet period, an empty "Loaded" list now comes with the reason. - More
Laya::fake().when()gives states that match a callback their own answers.sequence()hands out answers in order.assertNotPredicted()andassertNotDecided()check that something didn't happen.- The fake dispatches events, answers
#[Of]and#[Scale]parameters, and appliesminConfidencethe way laya-serve does.
- New example.
examples/07-model-routing.phproutes each prompt to the cheapest Claude model that can handle it.
Improved
- The cache is best-effort. If a cache read throws or returns something that isn't a laya response, Laya treats it as a miss. If a write throws, you still get the result, just uncached. Both dispatch
CacheFailed. - Identical states in one
predictMany()call are sent once. Each copy still gets its own result and its ownPredictionMade. - Decision classes are reflected once per process.
decide(),decideMany()andassertDecided()share one cached reading of each class. - A
PredictionFailedlistener that throws no longer hides the laya error. You still get the originalLayaException, such asServerBusyException.
Upgrading
There are no breaking API changes, but a few things behave differently:
- Responses that don't fit the decision class throw
ServerException. When laya-serve's answers are missing, have the wrong type or carry a label outside the enum,decide()anddecideMany()used to throwOutOfBoundsException,UnexpectedValueExceptionorValueError. They now throw aServerExceptionthat names the parameter and keeps the original exception asprevious. CatchLayaExceptionto handle these along with the other laya errors. - Events are on by default in Laravel. If you listen for nothing, they cost next to nothing. To turn them off, set
LAYA_EVENTS=false. psr/simple-cache^1.0 and ^2.0 are allowed as well as ^3.0.- Laya 0.4.0 changes the default routing.
compose.yamlnow builds laya v0.4.0 (3cf26cb). In 0.4.0, text whose language laya can't identify, such as very short text or text without letters, goes to themultilingualcheckpoint instead ofenglish. If your traffic is mostly English, start laya-serve withLAYA_DEFAULT_MODEL=englishto keep the old behaviour.Laya::fake()now reportsmultilingualwhen no model is pinned.
Each feature needs a minimum laya-serve version:
| Feature | laya-serve |
|---|---|
minConfidence per bucket, idle-unload fields |
0.3.25 |
| Multilingual default routing | 0.4.0 |
1.2.1
laya-php now works with any server that follows TypeSafe's OpenAPI spec for POST /v1/systemone, such as sys1, not just laya-serve. CI now checks requests and responses against that spec on every change and every week.
Fixed
- Answers without
answer_confidence. Servers that only follow the spec don't sendanswer_confidence. Before,answerConfidencewas0.0for them, so everyminConfidencecheck failed. It now falls back to the answer'sconfidence. If a yes/no answer has noconfidenceeither, it gets laya's ownmax(P(yes), P(no)). Thresholds andminConfidencekeep working, thoughconfidenceis stricter than the calibrated value for choice and score answers. - Requests always name a model. The spec requires a
modelstring. When you don't pin aModel, requests now send"model": "jev-latest"instead of leaving the field out. laya-serve still routes by language for that name, and sys1 reads it as the model it serves.Laya::fake()still reportsnullto assertions when laya routes, so tests don't change.
Upgrading
No breaking changes and no new requirements. The only change on the wire is the model field on requests that don't pin a checkpoint. laya-serve routes them the same as before, but the cache key includes the request, so those requests miss the cache once after you upgrade.
Full Changelog: 1.2.0...1.2.1
1.2.0
You can now ask the same questions about many states in one call. laya-serve 0.3.22 answers them in shared forward passes instead of one request per state. Results also tell you when laya cut a state off, so you know when to raise maxLen.
$results = $laya->predictMany($tickets->pluck('body', 'id')->all(), $questions);
$results[42]->choice('department');
$triages = $laya->decideMany($tickets->pluck('body', 'id')->all(), Triage::class, maxLen: 4096);New
-
predictMany()anddecideMany()(experimental). They ask one set of questions about many states. Results keep the keys you pass in, so$results[42]belongs to ticket 42. Cached states aren't sent again, and the rest go out in requests of at most 64 states. They share the cache withpredict(), so a state you already predicted on its own costs nothing in a batch. -
maxLenandheadMaxLenon batches. They work the same as onpredict()and apply to every state in the batch. -
$result->truncated. It'struewhen laya cut the state off at its token budget. Check it and retry with a highermaxLen:$result = $laya->predict($contract, $questions); if ($result->truncated) { $result = $laya->predict($contract, $questions, maxLen: 4096); }
-
Laya::fake()supports batches. Each state in a batch is recorded as its own prediction, soassertPredicted(),assertDecided()andassertPredictedCount()work the same whether your code batches or not. -
New example.
examples/06-contract-fields.phpasks which contract fields a clause is relevant to.
Good to know
- One state that laya-serve rejects fails the whole batch with an exception, the same way
predict()throws. predictMany()anddecideMany()are experimental, so their signatures may change in a minor release.
Upgrading
No breaking changes. Existing calls send the same requests as in 1.1.1 and hit the same cache entries.
Each feature needs a minimum laya-serve version:
| Feature | laya-serve |
|---|---|
predictMany(), decideMany(), $result->truncated |
0.3.22 |
maxLen / headMaxLen on batches |
0.3.23 |
A server older than 0.3.22 answers a batch with a ServerException that names the version it needs, and always reports truncated as false. A 0.3.22 server ignores batch budgets without an error.
compose.yaml now builds laya v0.3.23 (d8a2e59) for local development and the integration tests.
1.1.1
Laya used to cut every state off at the checkpoint's default length, 512 or 1,024 tokens, and there was nothing you could do about it from PHP. laya-serve 0.3.21 lets a request raise that limit, and 1.1.1 passes it through.
$laya->predict($contract, $questions, maxLen: 4096);
$laya->decide($ticket, Triage::class, headMaxLen: 384);New
maxLenonpredict()anddecide(). It sets the token budget for the state. A long contract or email thread no longer loses its second half.headMaxLenonpredict()anddecide(). It sets the token budget for each question and its options. Raise it when a choice question with many or long option descriptions comes back as a 422.- Separate cache entries. Different budgets are cached separately, because the budgets are part of the request.
laya-serve caps both values at LAYA_MAX_TOKEN_BUDGET, 8,192 by default. It answers anything higher, zero or negative with a 422, which you get as a ValidationException.
Upgrading
No breaking changes. If you leave both arguments out, the request is byte for byte the same as in 1.1.0.
The new arguments need laya-serve 0.3.21 or later. A 0.3.20 server ignores them without an error and keeps using the default length, so upgrade the server before you rely on them.
compose.yaml now builds laya v0.3.21 (9d95567) for local development and the integration tests.
1.1.0
Until now, decide() gave you a typed object and dropped the confidence behind it. So the moment you wanted to send unsure tickets to a human, you had to fall back to predict(). 1.1.0 moves that check into the decision class, and adds caching and a health check for production.
final readonly class Triage
{
public function __construct(
#[Ask('Which department should handle this?', minConfidence: 0.7)]
public ?Department $department, // null when laya isn't sure
#[Ask('Does the user threaten to cancel?', yes: 'says they will cancel or switch', threshold: 0.3)]
public bool $churn,
) {}
}New
minConfidenceon#[Ask]. A nullable parameter isnullwhen the calibratedanswerConfidenceis below the threshold. If the parameter isn't nullable,decide()throws before it sends a request.yes,noandthresholdfor bools.yesandnodescribe what each answer means, the same asQuestion::yesNo().thresholdsets the P(yes) at which the value becomestrue, instead of the fixed 0.5. Setting any of them on a parameter that isn't a bool throws, so a typo can't be silently ignored.- Fake a decision object.
Laya::fake(new Triage(...))takes its answers from the object's public properties, so tests build the same type your code gets back, and there are no string keys to go stale when you rename a field. - Unsure fake answers. Register
nulland the fake answers with even probabilities and zero confidence. That lets you test theminConfidencepath without a server. assertDecided(Triage::class). It passes when a recorded request asked exactly that class's questions. An optional callback gets the state and the model.- PSR-16 caching. Pass
cache:andcacheTtl:to the constructor. The key is a hash of the state, the questions and the pinned model. Laya gives the same answer to the same input, so repeat requests never reach the server. The key doesn't include the checkpoint revision, so clear the cache or set a TTL when you upgrade laya-serve. - Laravel caching. Set
LAYA_CACHE_STORE(e.g.redis) and optionallyLAYA_CACHE_TTLin seconds. Caching stays off by default. php artisan laya:health. It prints the server's status, device and loaded checkpoints, and exits with 1 when laya-serve is unhealthy or unreachable. Put it in your deploy checks.
Upgrading
No breaking changes. The one new dependency is psr/simple-cache ^3.0, which only contains interfaces. A published config/laya.php picks up the new cache block from the package defaults, so the env vars work without republishing.
1.0.0
Classify text in PHP without paying for an LLM API. laya-php is a typed SDK for Laya, a self-hosted model that answers classification questions about text in 100+ languages, and gives you calibrated confidence with every answer.
1.0.0 is the first stable release. From here on the public API follows semver.
$triage = $laya->decide($ticket, Triage::class);
$triage->department; // Department::Billing
$triage->churn; // trueWhat's in 1.0
- Three question types.
Question::choice(),Question::score()andQuestion::yesNo()return typed answers with probabilities and a calibratedanswerConfidence, which is comparable across question types. - Decisions into objects.
$laya->decide($text, Triage::class)reads#[Ask],#[Describe]and#[Levels]attributes from a class and returns a hydrated instance. Backed enums map to choices, bools to yes/no and#[Levels]ints to scores. - Any PSR-18 client. Guzzle, Symfony HttpClient and others are auto-discovered, or you can pass your own client, for example one with retries.
- Laravel integration (new since 0.0.1). For Laravel 13+, the service provider is auto-discovered and reads
LAYA_URLandLAYA_API_KEY. Publish the config withphp artisan vendor:publish --tag=laya-config. - Testing without a server.
Laya::fake([...])answers from values you register, and offersassertPredicted(),assertPredictedCount()andassertNothingPredicted(). In Laravel it also replaces the container binding, so injected code and queued jobs get the fake. - Typed exceptions. They cover authentication, validation, a busy server, a failed transport and other server errors, and all implement
LayaException. - Checkpoint pinning. Pass
model: Model::English,Model::MultilingualorModel::TypedDecisions. Otherwise Laya's router picks a checkpoint by language. - Jev-compatible. laya-serve speaks the same
/v1/systemonewire protocol, which makes this a self-hosted alternative to Jev AI. It hasn't been tested against the hosted Jev API.
Quality
- 100% line coverage and a 100% mutation score (Pest), both enforced in CI
- PHPStan at level max
- Tested on PHP 8.4 and 8.5
- Integration-tested against a live laya-serve;
compose.yamlpins upstream Laya to commit4066d5d
Requirements
- PHP 8.4+
- A running laya-serve; the repository's
compose.yamlstarts one - Laravel 13+ (optional)
Upgrading from 0.0.1
No breaking changes. Laravel users can drop any manual Laya binding and use the auto-discovered provider.
composer require marcreichel/laya-php:^1.0