Package
Marcreichel Laya Php
Self-hosted multilingual text classification for PHP: typed decisions from Laya (via laya-serve), no LLM API needed.
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 hasn't been tested against the hosted Jev API.
Installation
composer require marcreichel/laya-php
PHP 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 commit d8a2e59) or follow Laya's Docker guide:
docker compose up -d --wait # http://localhost:8000
Runnable 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.
Laravel
For Laravel 13+, the service provider is auto-discovered. It registers Laya as a singleton, configured from your .env:
LAYA_URL=http://localhost:8000LAYA_API_KEY=LAYA_CACHE_STORE= # e.g. redis, to cache predictions (see Caching)LAYA_CACHE_TTL= # seconds
php artisan laya:health prints the server's status and loaded checkpoints, and exits with 1 when it is unreachable or unhealthy, so you can use it in deploy checks.
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);
Asking questions
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 picked
The 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();}
State
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);
Picking a checkpoint
By default, laya's router picks a checkpoint by language. To pin one:
use MarcReichel\Laya\Model; $laya->predict($text, $questions, model: Model::Multilingual);
Long documents
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);}
Batches (experimental)
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, and the rest go out in requests of at most 64 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.
Decisions into objects
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 |
Every parameter needs #[Ask]. Any other type throws an InvalidQuestionException that names the parameter.
#[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 |
yes, no |
bool |
describe what yes and no mean |
threshold |
bool |
true when P(yes) ≥ threshold (default 0.5) |
For the full probabilities, use predict().
Recipes
Routing incoming email
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');
Moderating reviews
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();}
Scoring leads
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();
Finding relevant contract fields
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.
Caching
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.
Errors
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 |
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 |
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())));
Testing your code
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'));
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.
Limitations
- 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.
Development
composer test # Pestcomposer test:coverage # Pest with coverage (Xdebug or pcov), fails below 100%composer test:mutate # Pest mutation testing, fails below a 100% scorecomposer analyse # PHPStan (max)composer lint # Pint docker compose up -d --waitLAYA_URL=http://localhost:8000 composer test:integration
License
Apache-2.0