Classify text in PHP without paying for an LLM API. Route support tickets, spot churn risk and score urgency, in 100+ languages, on your own server, and get the answers back as typed enums, ints and bools.
$triage = $laya->decide('Hi, we were billed twice for March. Refund it today or we cancel.', Triage::class);
$triage->department; // Department::Billing
$triage->churn; // true- Self-hosted: the text never leaves your infrastructure, and you pay no per-token fees.
- Typed: describe a decision as a readonly class with enums and get an instance back.
- Calibrated: every answer has a confidence you can threshold on to send unsure cases to a human.
- Testable: a built-in fake lets you unit-test without a running server.
Under the hood this is an SDK for Laya, a multilingual decision engine that answers typed questions (choice, score, yes/no) about any text in a single forward pass. Laya runs in Python, so the SDK talks to laya-serve, Laya's HTTP server, over any PSR-18 client.
Looking for Jev? laya-serve speaks the same POST /v1/systemone protocol as TypeSafe's hosted Jev API, with the same choice/score/noul answers, so this package is also a self-hosted Jev AI alternative for PHP. It targets laya-serve, and CI checks its requests and responses against TypeSafe's OpenAPI spec on every change and weekly, so other /v1/systemone servers such as sys1 work too. It hasn't been tested against the hosted Jev API itself.
composer require marcreichel/laya-phpPHP 8.4+. You also need a PSR-18 HTTP client (Guzzle, Symfony HttpClient, …). The SDK finds the installed one automatically.
To run laya-serve locally, use the compose.yaml in this repository (it pins upstream Laya to v0.4.0, commit 3cf26cb) or follow Laya's Docker guide:
docker compose up -d --wait # http://localhost:8000Runnable scripts are in examples/. Set LAYA_URL (and LAYA_API_KEY) if your server isn't on localhost:8000. 05-testing-with-fake.php runs without a server.
For Laravel 13+, the service provider is auto-discovered. It registers Laya as a singleton, configured from your .env:
LAYA_URL=http://localhost:8000
LAYA_API_KEY=
LAYA_CACHE_STORE= # e.g. redis, to cache predictions (see Caching)
LAYA_CACHE_TTL= # seconds
LAYA_EVENTS=true # dispatch PredictionMade/PredictionFailed/CacheFailed (see Events)
LAYA_EVENTS_INCLUDE_STATE=falsephp artisan laya:health prints the server's status and loaded checkpoints (plus the idle-unload window and idle time when laya-serve runs with LAYA_IDLE_UNLOAD_SECONDS, since an idle unload leaves "Loaded: none" on a healthy server), and exits with 1 when it is unreachable or unhealthy, so you can use it in deploy checks.
php artisan laya:try runs a decision class on a piece of text and shows each parameter's question, hydrated value (null below minConfidence), answer confidence and the probability of every option, plus the routed checkpoint and a warning when the text was cut off. Use it to iterate on #[Ask] and #[Describe] wording:
php artisan laya:try "App\Decisions\Triage" "Hi, we were billed twice for March. Refund it today or we cancel."
php artisan laya:try "Decisions\Triage" --file=ticket.txt # short names resolve under App\
echo "..." | php artisan laya:try "Decisions\Triage" --model=multilingual --jsonIt takes --model=, --max-len=, --head-max-len= and --json, and exits with 1 for an unknown or invalid class or an unreachable server.
Inject it wherever you need it:
use MarcReichel\Laya\Laya;
final class ClassifyTicket implements ShouldQueue
{
use Queueable;
public function __construct(public Ticket $ticket) {}
public function handle(Laya $laya): void
{
$triage = $laya->decide($this->ticket->body, Triage::class);
$this->ticket->update(['department' => $triage->department]);
}
}To change the config file, publish it with php artisan vendor:publish --tag=laya-config. In tests, Laya::fake([...]) also replaces the container's instance, so injected code gets the fake (see Testing your code):
$laya = Laya::fake(['department' => Department::Billing, 'urgency' => 2, 'churn' => true]);
ClassifyTicket::dispatchSync($ticket);
$laya->assertPredictedCount(1);use MarcReichel\Laya\Laya;
use MarcReichel\Laya\Question;
$laya = new Laya('http://localhost:8000', apiKey: getenv('LAYA_API_KEY') ?: null);
$result = $laya->predict($ticketText, [
'department' => Question::choice('Which department should handle this?', [
'billing' => 'invoices, payments, refunds',
'technical' => 'bugs, outages, system errors',
'other' => 'everything else',
]),
'urgency' => Question::score('How urgent is this?', ['not urgent', 'soon', 'blocking']),
'churn_risk' => Question::yesNo('Does the user threaten to cancel or leave?'),
]);
$result->choice('department')->choice; // 'billing'
$result->choice('department')->probabilities; // ['billing' => 0.91, 'technical' => 0.06, 'other' => 0.03]
$result->score('urgency')->level(); // 2: the most likely level
$result->score('urgency')->label(); // 'blocking'
$result->score('urgency')->score; // 1.74: the expected level
$result->yesNo('churn_risk')->yes(); // true
$result->yesNo('churn_risk')->probability; // 0.83
$result['department']; // ArrayAccess works too (returns the base Answer type)
$result->routedModel; // 'english': the checkpoint laya pickedThe three question types:
| Factory | Options | Answer |
|---|---|---|
Question::choice($instructions, $options) |
a list of labels, or label => description |
ChoiceAnswer: choice, probabilities, is($label) |
Question::score($instructions, $levels) |
level descriptions, lowest first | ScoreAnswer: score, level(), label(), probabilities, legend |
Question::yesNo($instructions, yes: …, no: …) |
optional descriptions of yes and no | YesNoAnswer: probability, yes($threshold = 0.5), no() |
Every answer also has confidence and answerConfidence. answerConfidence is the calibrated one and is comparable across question types, so use it to decide when to trust an answer:
if ($result->choice('department')->answerConfidence < 0.7) {
$ticket->sendToHumanTriage();
}Servers that only follow TypeSafe's OpenAPI spec, such as sys1, don't send an answer_confidence. Then answerConfidence falls back to confidence, and a yes/no answer without a confidence gets laya's own max(P(yes), P(no)). Thresholds and minConfidence keep working, though confidence is stricter than the calibrated value for choice and score answers. The same applies to laya-serve 0.3.24 or later started with LAYA_JEV_STRICT=1, which strips answer_confidence, routing and the truncation report from its responses, so routedModel is null and truncated is always false there.
laya-serve can gate answers itself. Pass minConfidence, and every answer reports how the gate decided it, measured on answerConfidence:
use MarcReichel\Laya\Abstention;
$result = $laya->predict($ticket, $questions, minConfidence: 0.7);
$result->choice('department')->abstention; // Abstention::Abstained
$result->choice('department')->abstentionThreshold; // 0.7
$result->choice('department')->lowConfidence; // true: below the threshold
$result->choice('department')->choice; // 'billing': laya keeps the answer either wayabstention is Passed, Abstained, or Unevaluated when the answer carried no usable confidence. Without minConfidence, it and abstentionThreshold are null and lowConfidence is false.
One threshold doesn't transfer across option counts, so laya-serve 0.3.25 and later also take a threshold per type and option count (2, 3-5, 6-10 or 11+), with default for the rest. Fit one with laya.calibrate.fit_abstention_thresholds:
$laya->predict($ticket, $questions, minConfidence: ['choice:3-5' => 0.6, 'noul:2' => 0.8, 'default' => 0.7]);predictMany(), decide() and decideMany() take minConfidence too. A threshold outside 0 to 1, an empty map or a bucket laya doesn't know throws an InvalidOptionException before anything is sent. #[Ask(minConfidence: ...)] on a decision class is a separate gate, on the client: it turns the parameter into null.
state can be a string, an array (a JSON document, or a list of conversation turns), or any JsonSerializable, such as your own models:
$laya->predict(['subject' => $mail->subject, 'body' => $mail->body], $questions);By default, laya's router picks a checkpoint by language: text identified as English goes to the english checkpoint, everything else to multilingual. Since Laya 0.4.0 that includes text whose language it can't identify, such as very short text, text without letters ("12345 !!!") or a few Latin-script content words ("Quero cancelar"); before, those went to english. For mostly-English traffic, start laya-serve with LAYA_DEFAULT_MODEL=english to restore the old behaviour (upstream puts the break-even at about 62% English). The request carries "model": "jev-latest", since TypeSafe's spec requires a model name: laya-serve routes for names it doesn't know, and sys1 reads it as the model it serves. To pin one:
use MarcReichel\Laya\Model;
$laya->predict($text, $questions, model: Model::Multilingual);Laya cuts the state off at the checkpoint's default length (512 or 1,024 tokens). With laya-serve 0.3.21 or later you can raise it per request with maxLen, and give questions with many or long options more room with headMaxLen:
$laya->predict($contract, $questions, maxLen: 4096);
$laya->decide($ticket, Triage::class, headMaxLen: 384);laya-serve caps both at LAYA_MAX_TOKEN_BUDGET (8,192 by default) and answers anything above it with a ValidationException.
From laya-serve 0.3.22, $result->truncated tells you whether the state was cut off, so you know when to raise maxLen:
$result = $laya->predict($contract, $questions);
if ($result->truncated) {
$result = $laya->predict($contract, $questions, maxLen: 4096);
}predictMany() and decideMany() ask the same questions about many states, which laya-serve 0.3.22 and later answers in shared forward passes. Results keep the keys you pass in:
$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);Cached states aren't sent again, identical states in one call are sent once and share the result, and the rest go out in requests of at most 64 distinct states. One state that laya-serve rejects fails the whole request, the same way predict() throws. maxLen and headMaxLen apply to every state in the batch and need laya-serve 0.3.23; older servers ignore them. A laya-serve older than 0.3.22 answers with a ServerException that names the version it needs.
Both methods are experimental and may change in a minor release.
Describe the decision as a class, and decide() asks its questions and gives you an instance back:
use MarcReichel\Laya\Attributes\Ask;
use MarcReichel\Laya\Attributes\Describe;
use MarcReichel\Laya\Attributes\Levels;
enum Department: string
{
#[Describe('invoices, payments, refunds')]
case Billing = 'billing';
#[Describe('bugs, outages, system errors')]
case Technical = 'technical';
case Other = 'other';
}
final readonly class Triage
{
public function __construct(
#[Ask('Which department should handle this?')]
public Department $department,
#[Ask('How urgent is this?'), Levels('not urgent', 'soon', 'blocking')]
public int $urgency,
#[Ask('Does the user threaten to cancel or leave?')]
public bool $churn,
) {}
}
$triage = $laya->decide($ticketText, Triage::class); // Triage| Constructor parameter | Question | Value |
|---|---|---|
| backed enum | choice (case values are the options; #[Describe] adds descriptions) |
the most likely case |
bool |
yes/no | true when P(yes) ≥ 0.5 |
int with #[Levels(...)] |
score | the most likely level index |
backed enum marked #[Scale] |
score (cases are the levels, lowest first; #[Describe] text or the value) |
the case at the most likely level |
array with #[Of(Enum::class)] |
one yes/no per case of a backed enum | the cases with P(yes) ≥ 0.5, in declaration order |
Every parameter needs #[Ask]. Any other type throws an InvalidQuestionException that names the parameter.
A bare level index gives you $triage->urgency === 2. To get a case instead, mark the enum with #[Scale]. Its cases become the levels of a score question in declaration order, lowest first, whatever their backing values:
use MarcReichel\Laya\Attributes\Scale;
#[Scale]
enum Urgency: int
{
#[Describe('can wait a week or more')]
case NotUrgent = 0;
#[Describe('should be handled in the next day or two')]
case Soon = 1;
#[Describe('blocks the customer right now')]
case Blocking = 2;
}
final readonly class Triage
{
public function __construct(
#[Ask('How urgent is this?')]
public Urgency $urgency,
) {}
}
$laya->decide($ticketText, Triage::class)->urgency; // Urgency::BlockingThe enum defines the levels, so #[Levels] on such a parameter throws. A backed enum without #[Scale] stays a choice question.
When several options can apply at once, such as the topics a message mentions, declare an array with #[Of]. laya gets one yes/no question per enum case, with {case} in the instructions replaced by the case's #[Describe] text (or its value):
use MarcReichel\Laya\Attributes\Of;
enum Topic: string
{
#[Describe('invoices, payments, refunds')]
case Billing = 'billing';
#[Describe('login, passwords, 2FA')]
case Account = 'account';
case Shipping = 'shipping';
}
final readonly class Tagging
{
public function __construct(
/** @var list<Topic> */
#[Ask('Does this message mention {case}?', threshold: 0.3), Of(Topic::class)]
public array $topics,
) {}
}
$laya->decide($text, Tagging::class)->topics; // [Topic::Billing, Topic::Account]No case passing gives []. The questions have ids such as topics.billing, which is how laya:try and PredictionMade show them. Each case is a question of its own, and laya-serve answers at most 64 questions per request, so a class with more (counting its other parameters) throws an InvalidQuestionException.
#[Ask] takes a few options on top of the question:
final readonly class Triage
{
public function __construct(
// null when the calibrated confidence is below 0.7, so you can hand the ticket to a human
#[Ask('Which department should handle this?', minConfidence: 0.7)]
public ?Department $department,
// bools can describe yes and no, and pick the P(yes) from which they are true
#[Ask('Does the user threaten to cancel or leave?', yes: 'says they will cancel or switch', threshold: 0.3)]
public bool $churn,
) {}
}
$triage = $laya->decide($ticketText, Triage::class);
if ($triage->department === null) {
$ticket->sendToHumanTriage();
}| Option | Applies to | Effect |
|---|---|---|
minConfidence |
any nullable parameter | null when answerConfidence is below it (for ?array with #[Of]: when any case's is) |
yes, no |
bool |
describe what yes and no mean |
threshold |
bool, array with #[Of] |
true, or the case is listed, when P(yes) ≥ threshold (default 0.5) |
For the full probabilities, use predict().
Pass the email as a document and give unsure answers to a human. German, Spanish or Hindi emails work the same way, with no extra setup.
$result = $laya->predict([
'from' => $mail->from,
'subject' => $mail->subject,
'body' => $mail->body,
], [
'team' => Question::choice('Which team should answer this email?', [
'sales' => 'pricing, quotes, new contracts',
'support' => 'problems using the product',
'billing' => 'invoices, payments, refunds',
'spam' => 'newsletters, cold outreach, phishing',
]),
]);
$team = $result->choice('team');
$inbox->assign($mail, $team->answerConfidence >= 0.7 ? $team->choice : 'triage');Hold abusive or spam reviews back before they are published, and record the sentiment while you're at it.
enum Sentiment: string
{
case Positive = 'positive';
case Neutral = 'neutral';
case Negative = 'negative';
}
final readonly class Moderation
{
public function __construct(
#[Ask('Does the review contain insults, hate speech or threats?')]
public bool $abusive,
#[Ask('Is this spam or an advertisement rather than a real review?')]
public bool $spam,
#[Ask('What is the overall sentiment of the review?')]
public Sentiment $sentiment,
) {}
}
$moderation = $laya->decide($review->body, Moderation::class);
if ($moderation->abusive || $moderation->spam) {
$review->holdForModeration();
}A score question's score is the expected level, a float, so leads with the same most likely level still sort cleanly.
$result = $laya->predict($lead->message, [
'intent' => Question::score('How ready is this person to buy?', [
'just browsing',
'researching options',
'comparing vendors',
'ready to buy',
]),
'budget' => Question::yesNo('Does the message mention a budget, a timeline or a team size?'),
]);
$lead->score = $result->score('intent')->score; // 0.0 to 3.0
$lead->hot = $result->score('intent')->level() === 3 && $result->yesNo('budget')->yes();Before you look anything up, find out which of a contract's fields a question needs. A yes/no question per field catches questions that touch several fields. One choice question over all fields is sharper when a single field is meant. Both go in the same request:
$fields = [
'notice_period' => 'how far in advance either side must give notice to end the contract',
'auto_renewal' => 'whether and for how long the contract renews automatically',
'governing_law' => 'which country\'s law applies',
'jurisdiction' => 'which court handles disputes',
// ...
];
$questions = array_map(
fn (string $description) => Question::yesNo("Do you need to know the contract's clause on {$description} to answer this question?"),
$fields,
) + ['main_clause' => Question::choice('Which contract clause do you need to answer this question?', $fields)];
$result = $laya->predict('Who do we sue in if things go wrong, and under which law?', $questions);
$relevant = array_filter(array_keys($fields), fn (string $field) => $result->yesNo($field)->yes(threshold: 0.2));
if ($result->choice('main_clause')->answerConfidence >= 0.7) {
$relevant[] = $result->choice('main_clause')->choice;
}
$relevant = array_unique($relevant); // ['governing_law', 'jurisdiction', ...]Yes/no probabilities for this kind of question run low, so the threshold is 0.2 instead of 0.5. The full version with 20 fields is in examples/06-contract-fields.php.
Laya gives the same answer to the same input, so you can skip repeat requests with any PSR-16 cache. The key covers the state, the questions, the pinned model and the token budgets:
$laya = new Laya('http://laya:8000', cache: $psr16Cache, cacheTtl: 86400);The key doesn't include the checkpoint revision, so clear the cache (or set a TTL) when you upgrade laya-serve's checkpoints.
The cache is best-effort: it never fails a prediction. If reading it throws (say, Redis is unreachable), or an entry isn't a laya response, Laya asks laya-serve and overwrites the entry with the answer. If writing it throws, you get the result anyway, uncached; in a batch, every state still gets its result. Only exceptions are caught, so an Error still surfaces. With an event dispatcher, each failure dispatches a CacheFailed (see Events); without one, failures are silent.
To monitor predictions (timings, cache hits, routing, failures), pass any PSR-14 event dispatcher:
$laya = new Laya('http://laya:8000', events: $psr14Dispatcher);| Event | When | Payload |
|---|---|---|
PredictionMade |
after each predict(), and after each state of a predictMany() (so also decide()/decideMany()) |
questionIds, model (pinned, or null), routedModel, truncated, cached, durationMs, inputTokens, result |
PredictionFailed |
when a request to laya-serve fails, right before the exception is thrown; once per failed batch request | questionIds, model, exception, durationMs |
CacheFailed |
when a cache read throws or returns an entry that isn't a laya response, or a cache write throws; the prediction goes on (see Caching) | operation (get or set), key, exception |
All are readonly classes in MarcReichel\Laya\Events. Cache hits have a durationMs of 0, and the states of a batch share the duration of the request they were sent in. Identical states in a batch are sent once, but each still gets its own PredictionMade, with that request's duration and cached false. A batch dispatches its events as answers arrive, not in input order: cache hits first, then the states of each request (copies included) as it returns. A question or decision class that is malformed throws before any request, without an event. If a PredictionFailed listener throws, its exception is dropped, so you still get the laya error; the same goes for a CacheFailed listener, so you still get the result.
The events leave the state out, since it may be sensitive. Pass includeState: true to get it as $event->state (for a failed batch, every state of that request, identical ones included, keyed as you passed them). Without a dispatcher, no events are built.
In Laravel, the service provider passes the app's event dispatcher, so listeners and Event::fake() work as usual:
use MarcReichel\Laya\Events\PredictionMade;
Event::listen(function (PredictionMade $event) {
Log::info('laya', ['model' => $event->routedModel, 'cached' => $event->cached, 'ms' => $event->durationMs]);
});Set LAYA_EVENTS=false to turn them off, or LAYA_EVENTS_INCLUDE_STATE=true to include the state.
Laya::fake() dispatches PredictionMade too. In Laravel it uses the app's dispatcher and settings, so you can assert on the events in your tests:
Event::fake();
Laya::fake(['churn' => true]);
ClassifyTicket::dispatchSync($ticket);
Event::assertDispatched(PredictionMade::class);Outside Laravel, pass a dispatcher: Laya::fake([...], events: $psr14Dispatcher, includeState: true).
Everything the SDK throws implements MarcReichel\Laya\Exceptions\LayaException.
| Exception | When |
|---|---|
InvalidQuestionException |
a question or decision class is malformed; thrown before any request is sent |
InvalidOptionException |
a request option is malformed, such as a minConfidence outside 0 to 1; thrown before any request is sent |
ValidationException |
laya rejected the request (400/413/422); the message names the problem |
AuthenticationException |
wrong or missing API key (401) |
ServerBusyException |
laya-serve is at its concurrency limit (503); safe to retry |
ServerException |
any other error status, or a response that isn't laya-shaped or doesn't fit the decision class (a missing answer, another answer type, a label that isn't an enum case) |
TransportException |
laya-serve couldn't be reached |
The SDK doesn't retry. For retries, pass an HTTP client that has them, such as Symfony's RetryableHttpClient or Guzzle with retry middleware:
use Symfony\Component\HttpClient\{HttpClient, Psr18Client, RetryableHttpClient};
$laya = new Laya('http://laya:8000', httpClient: new Psr18Client(new RetryableHttpClient(HttpClient::create())));Laya::fake() returns a client that answers from values you register, with no server involved:
$laya = Laya::fake([
'department' => Department::Billing, // or 'billing'
'urgency' => 2, // level index
'churn' => true, // or a probability, e.g. 0.3
]);
// ... run the code under test with $laya ...
$laya->assertPredictedCount(1);
$laya->assertPredicted(fn ($state, array $questions, ?string $model) => str_contains($state, 'refund'));
$laya->assertNothingPredicted();For decision classes, fake with an instance and assert on the class. A null answer (here or in the array form) is an unsure one: even probabilities and zero confidence, so minConfidence turns it into null again:
$laya = Laya::fake(new Triage(department: null, churn: true));
// ... run the code under test with $laya ...
$laya->assertDecided(Triage::class);
$laya->assertDecided(Triage::class, fn ($state, ?string $model) => str_contains($state, 'refund'));A #[Scale] parameter takes a case, which the fake answers with that case's level: Laya::fake(['urgency' => Urgency::Blocking]). A plain int is a level index, as for any score question.
An #[Of] parameter takes the cases (or their values) that apply, and the fake answers each case's question with yes or no: Laya::fake(['topics' => [Topic::Billing, 'account']]).
For code that treats states differently, give some states other answers with when(). Rules are checked in the order you add them, the first match wins, and its answers are merged over the defaults you passed to fake():
$laya = Laya::fake(['department' => 'other', 'churn' => false])
->when(fn ($state) => str_contains($state, 'refund'), ['department' => 'billing', 'churn' => true])
->when(fn ($state) => str_contains($state, 'outage'), new Triage(Department::Technical, urgency: 2, churn: false));Or answer in order with sequence(). States that no when() rule matches take the next answer, and once all are used the fake throws:
$laya = Laya::fake()->sequence(
['department' => 'billing'],
['department' => 'technical'],
);Both take an array or a decision instance, and match each state of a batch on its own. Identical states in one batch are sent once, so they share an answer and are recorded once. To check what didn't happen, or how often something did:
$laya->assertNotPredicted(fn ($state) => str_contains($state, 'secret'));
$laya->assertNotDecided(Triage::class);
$laya->assertDecidedCount(Triage::class, 2);With minConfidence, the fake gates its answers the way laya-serve does, so abstention, abstentionThreshold and lowConfidence follow from the answers you register.
The fake reports the model you pin as routedModel. Without one it reports 'multilingual', matching laya-serve's default for text whose language it can't identify.
If code asks a question you didn't register, or gives an answer that isn't one of the question's options, the fake throws.
- One inference at a time.
laya-servehandles one request at a time, so parallel requests just wait in line. For many states, usepredictMany(), which shares forward passes. - Server limits.
laya-servecaps requests at 64 states per batch, 64 questions, 50,000 characters of state, 100 choice options, 32 score levels and 512 answer options in total.
composer test # Pest
composer test:coverage # Pest with coverage (Xdebug or pcov), fails below 100%
composer test:mutate # Pest mutation testing, fails below a 100% score
composer analyse # PHPStan (max)
composer lint # Pint
docker compose up -d --wait
LAYA_URL=http://localhost:8000 composer test:integration
curl -fsSL https://api.typesafe.ai/openapi.json -o openapi.json
LAYA_OPENAPI=openapi.json composer test:contract # requests and responses against TypeSafe's spec- LayaPHP: Self-Hosted Text Classification for PHP and Laravel on Laravel News
Apache-2.0
