Extraction That Cannot Hallucinate: Regex Finds, Jev Picks, Laravel Normalises

Laravel data extraction without hallucination: a regex over-finds candidates, a Jev Choice question picks one, and PHP copies that span verbatim and normalises.

Steven Richardson
Steven Richardson
· 11 min read

A client's reconciliation run flagged a £180 gap three weeks after the invoices were ingested. The extracted total was 1135.50. The invoice said 1315.50. Nothing had errored, no validation had failed, and the model had reported itself confident — because to a text model, writing 1135.50 when it read 1315.50 is not an error state, it is just a slightly different sequence of tokens. The fix for Laravel data extraction without hallucination is not a better prompt or a second model checking the first. It is to stop letting the model type the value at all.

Understand why Laravel data extraction without hallucination starts with a regex#

Generative extraction fails on values for a structural reason. The model emits characters one at a time, so a transposition sits inside the normal output distribution rather than outside it. Schema validation does not catch it: 1315.50 and 1135.50 are both perfectly valid decimals. Neither does a confidence score from the same generative pass, because the model is confident about the shape of the answer, not the provenance of the digits.

TypeSafe's pre-parsed value extraction cookbook frames the alternative in three steps — find, pick, copy:

  1. Find. A regex over-finds every candidate span in the text. Code owns this step entirely.
  2. Pick. One Jev Choice question, whose options are those spans, selects the one the question is asking for.
  3. Copy. Your PHP takes the chosen string unchanged and normalises it with a deterministic library.

The guarantee falls out of step 2. Jev only ever returns one of the options you handed it, so the value you get back is a substring of the document by construction. It is the same reasoning I used when gating RAG passages with Jev before they reach the LLM: keep the model in the business of judging, and keep code in the business of producing strings.

Find every candidate span with a recall-tuned regex#

Write one finder per value type and tune each regex to over-find. Precision costs you nothing here — a surplus candidate is just an option Jev will not choose — but recall is a hard ceiling. A span the regex misses can never be picked, so find-recall and pick-accuracy are two separate numbers and you should measure them separately.

<?php

declare(strict_types=1);

namespace App\Support\Extraction;

interface CandidateFinder
{
    /** @return list<string> Deduped candidate spans, in document order. */
    public function find(string $text): array;

    public function name(): string;
}

The implementations are deliberately dull. Dedupe on the trimmed span, preserve document order, and never attempt to disambiguate — disambiguation is Jev's job.

<?php

declare(strict_types=1);

namespace App\Support\Extraction;

abstract readonly class RegexFinder implements CandidateFinder
{
    abstract protected function pattern(): string;

    public function find(string $text): array
    {
        preg_match_all($this->pattern(), $text, $matches);

        $seen = [];

        foreach ($matches[0] as $match) {
            $span = trim($match);

            // Keys dedupe and preserve insertion order in one pass.
            if ($span !== '') {
                $seen[$span] = true;
            }
        }

        return array_keys($seen);
    }
}

final readonly class AmountFinder extends RegexFinder
{
    // Currency symbol, optional space, grouped digits, optional 2dp. Over-finds on purpose.
    protected function pattern(): string
    {
        return '/[$€£¥]\s?\d[\d,.\s]*\d|\d[\d,]*\.\d{2}/u';
    }

    public function name(): string
    {
        return 'amount';
    }
}

final readonly class EmailFinder extends RegexFinder
{
    protected function pattern(): string
    {
        return '/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/u';
    }

    public function name(): string
    {
        return 'email';
    }
}

final readonly class PhoneFinder extends RegexFinder
{
    // Loose on separators: libphonenumber does the real validation after the pick.
    protected function pattern(): string
    {
        return '/\(?\+?\d[\d\s()\-.]{6,}\d/u';
    }

    public function name(): string
    {
        return 'phone';
    }
}

Two things to watch. Overlapping matches need collapsing before they become options — £1,315.50 and 1,315.50 as separate candidates split Jev's probability mass across two spellings of the same value. And if your documents are PDFs, the quality of the text layer decides everything upstream of here; I covered getting usable text out of them in asking a model about a PDF with document attachments.

Pick the right span with a single Jev Choice question#

The pick is one Choice question whose criteria map is built from the candidate array. There is still no official PHP SDK for TypeSafe, so this is the same thin HTTP client and typed Choice/Noul wrapper I built in batching thirteen Jev questions into one Laravel request — POST /v1/systemone with a state, a model and a map of question id to question.

<?php

declare(strict_types=1);

namespace App\Support\Extraction;

use App\Support\Jev\Choice;

final readonly class PickQuestion
{
    public const string NONE = 'none';

    /** @param list<string> $candidates */
    public static function for(array $candidates, string $instructions): Choice
    {
        // The option KEY is the span itself, so the answer is a verbatim copy of it.
        $criteria = array_fill_keys($candidates, '');

        // Always present. Without it the model must pick something, even from a document
        // that simply does not contain the value.
        $criteria[self::NONE] = 'None of these candidates is the requested value.';

        return new Choice(instructions: $instructions, criteria: $criteria);
    }
}

That none key is the single most important line in this article. Documents missing the value are the common case in any real corpus — a remittance advice with no VAT line, an email with no mobile number — and a Choice without an escape hatch forces a wrong answer with respectable confidence. Omitting it is how this pattern quietly stops working.

A Choice allows at most 255 options. Past that, narrow in two stages: pick the section first, then the span inside it, using the same beam-search shape as classifying into a deep category tree with Jev.

Copy the pick verbatim and normalise it in PHP#

Once the answer comes back, your code copies the chosen string and hands it to a deterministic library. Do not ask the model to format anything — a prompt that says "return the amount as a decimal" puts the model back in the business of typing digits, which is exactly what you just removed.

<?php

declare(strict_types=1);

namespace App\Support\Extraction;

use Brick\Money\Money;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;

final readonly class Normaliser
{
    /** Strip grouping and symbols, then let brick/money own the arithmetic. */
    public static function money(string $span, string $currency): Money
    {
        $digits = preg_replace('/[^\d.]/', '', str_replace(',', '', $span)) ?? '';

        return Money::of($digits, $currency);
    }

    /** $region comes from a companion Choice, not from parsing the digits. */
    public static function phone(string $span, string $region): string
    {
        $util = PhoneNumberUtil::getInstance();

        return $util->format($util->parse($span, $region), PhoneNumberFormat::E164);
    }
}

Install the two libraries that do the real work:

composer require brick/money giggsey/libphonenumber-for-php

brick/money refuses to silently round, which is what you want for an invoice total. libphonenumber turns (415) 555-0177 plus a region into +14155550177 — and note it needs the region, which the digits alone do not carry. That is the cue for the next step.

Ask the companion questions in the same request#

Jev ingests the state once per request, so additional questions cost a rounding error against the document you already sent. Ask the pick, the currency, and the credit-or-charge flag together rather than paying three round trips for them.

<?php

declare(strict_types=1);

namespace App\Support\Extraction;

use App\Support\Jev\Choice;
use App\Support\Jev\Noul;
use App\Support\Jev\QuestionSet;

final readonly class InvoiceQuestions
{
    /** @param list<string> $amounts */
    public static function for(array $amounts): QuestionSet
    {
        return QuestionSet::make()
            ->add('total', PickQuestion::for(
                $amounts,
                'Which amount is the total the customer must pay?',
            ))
            ->add('credit', PickQuestion::for(
                $amounts,
                'Which amount is a credit or refund already applied to this invoice?',
            ))
            ->add('currency', new Choice(
                instructions: 'What currency are the amounts on this document in?',
                criteria: ['GBP' => '', 'USD' => '', 'EUR' => '', 'JPY' => '', 'CAD' => ''],
            ))
            // Decimal convention differs by locale: 1,315.50 vs 1.315,50.
            ->add('comma_is_decimal', new Noul(
                instructions: 'Does this document use a comma as the decimal separator?',
            ));
    }
}

The comma_is_decimal question is the one people skip. £1,315.50 and €1.315,50 are the same number written two ways, and a normaliser that assumes one convention will be off by three orders of magnitude on the other. Ask, then branch in code.

Route on confidence into a Filament review queue#

Choice answers carry a confidence derived from the answer distribution, and that number is the whole operational story. Three bands, three code paths — and all three are yours, which is the point of confidence-gated work generally, as in confidence-gated ticket triage with Jev.

<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\Document;
use App\Support\Extraction\AmountFinder;
use App\Support\Extraction\InvoiceQuestions;
use App\Support\Extraction\Normaliser;
use App\Support\Extraction\PickQuestion;
use App\Support\Jev\Jev;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

final class ExtractInvoiceTotal implements ShouldQueue
{
    use Queueable;

    public function __construct(private readonly Document $document) {}

    public function handle(Jev $jev, AmountFinder $finder): void
    {
        $candidates = $finder->find($this->document->text);

        if ($candidates === []) {
            // No candidates means a finder problem, not a model problem. Never send
            // an empty option list — route it straight to a human.
            $this->document->flagForReview('no_candidates');

            return;
        }

        $answers = $jev->ask($this->document->text, InvoiceQuestions::for($candidates));
        $pick = $answers->choice('total');

        if ($pick === PickQuestion::NONE) {
            $this->document->flagForReview('no_total_present');

            return;
        }

        $total = Normaliser::money($pick, $answers->choice('currency'));
        $confidence = $answers->confidence('total');

        match (true) {
            $confidence >= 0.95 => $this->document->acceptTotal($total, $pick, $confidence),
            $confidence >= 0.70 => $this->document->queueForReview($total, $pick, $confidence),
            default => $this->document->flagForReview('low_confidence'),
        };
    }
}

Build the review queue as a real Filament resource rather than a log nobody opens — a list page with status tabs, the source document beside the picked span, and a one-click accept. The status-filter tabs on a Filament v5 list page give you that in about twenty lines, and the acceptance rate on the mid-confidence tab is the metric that tells you whether your thresholds are set correctly.

Prove Laravel data extraction without hallucination with a golden-set test#

There is one invariant worth asserting above all others: every value you store is a substring of the document it came from. Assert it in a Pest test over a real corpus and you have a regression guard that no prompt change can quietly break.

<?php

declare(strict_types=1);

use App\Support\Extraction\AmountFinder;
use App\Support\Extraction\InvoiceQuestions;
use App\Support\Extraction\PickQuestion;
use App\Support\Jev\Jev;
use Illuminate\Support\Facades\Http;

it('only ever returns a span that exists in the document', function (string $fixture): void {
    $document = file_get_contents(base_path("tests/Fixtures/invoices/{$fixture}.txt"));

    Http::fake([
        'api.typesafe.ai/*' => Http::response(
            json_decode(file_get_contents(base_path("tests/Fixtures/jev/{$fixture}.json")), true),
        ),
    ]);

    $candidates = (new AmountFinder)->find($document);
    $answers = app(Jev::class)->ask($document, InvoiceQuestions::for($candidates));
    $pick = $answers->choice('total');

    expect($pick)->toBeIn([...$candidates, PickQuestion::NONE]);

    if ($pick !== PickQuestion::NONE) {
        expect($document)->toContain($pick);
    }
})->with(['inv-2087', 'remittance-no-total', 'euro-decimal-comma', 'credit-note']);

Record the fixtures once against the live API, then run offline forever. Alongside that, keep a live golden set of a dozen real documents with hand-checked answers and run it on a schedule rather than in CI — the same split I argued for in LLM evals with a golden dataset in Pest and CI. The offline test guards the invariant; the live set tracks accuracy drift.

Avoid the four ways this pattern goes wrong#

A missing none option. The most common failure by a distance. Without it, a document that does not contain the value still produces an answer, and that answer will be confidently wrong.

Silent under-finding. Pick accuracy can be 100% while end-to-end accuracy is 70%, because the regex never offered the right span. Log the candidate count per document and alert on zero-candidate runs; they are a code bug, not a model one.

Overlapping candidates. £1,315.50, 1,315.50 and 315.50 from a greedy pattern are three options for one value. Collapse spans that are substrings of a longer match at the same offset before building the criteria map.

Very long documents. State and questions share a token budget, and a 40-page contract burns it. Trim the state to windows around the candidate offsets — a few hundred characters either side is usually enough context for the pick, and it keeps you well inside the limit.

Wrapping Up#

Start with one value type on one document class — invoice totals are the usual first win — and measure find-recall and pick-accuracy separately from day one. Once the substring invariant is under test, extending to emails, phone numbers and dates is a new finder and a new question, not a new architecture. From here, batching every question into one Jev request is what keeps the cost flat as you add value types, and a Jev-backed validation rule shows the same confidence-gating idea applied at the request boundary instead of in a queued job.

FAQ#

How do I extract data from documents without an LLM hallucinating values?

Do not ask the model to write the value. Use a regex to find every candidate span in the document, pass those spans as the options of a Jev Choice question, and have your code copy the chosen span unchanged. Because the model can only select from the array your code built, the returned value is a substring of the source document by construction.

What is pre-parsed value extraction?

It is the find-then-pick pattern described in TypeSafe's cookbook. Code pre-parses the document with a recall-tuned regex to produce candidate values, the model picks which candidate answers the question, and code normalises the pick. The model contributes judgement about which span plays which role; it never contributes characters.

How do I use a Jev Choice question in Laravel?

Build the criteria map with your candidate spans as keys, add a none key as an escape hatch, and POST it to /v1/systemone with the document as the state using Laravel's HTTP client. There is no official PHP SDK, so a thin wrapper of around thirty lines is the whole integration. The answer comes back as a choice string plus a confidence float.

Can Jev return a value that is not in the document?

No, provided you build the options from spans the regex actually matched. A Choice answer is always one of the option keys you supplied, so the only strings it can return are candidates from the document plus your none sentinel. That is why the substring assertion in the Pest test is worth having: it fails loudly if a refactor ever lets a hand-written option into the criteria map.

How do I handle documents where the value is missing?

Add a none option to every pick with an instruction such as "None of these candidates is the requested value", and branch on it before normalising. Also handle the case where the regex finds nothing at all by skipping the API call entirely and routing the document to review — an empty option list is a code bug, and sending one is worse than not asking.

Should I use Jev or the Laravel AI SDK for document extraction?

Use both, for different jobs. The Laravel AI SDK is the right tool when you need generated text, summaries or tool calls over a document. Jev is the right tool for a bounded decision over options you already have, which is exactly what picking a candidate span is. In practice the pipeline often runs an SDK call to get text out of the document and a Jev call to decide which span is the answer.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.