Index

Building a Rating Engine

Testing the Go Port Against PHP

A Go function can return the same failure code as the PHP function and still behave differently in the application. Laravel might replace the exception before the PHP resolver sees it. A database reader might fail while loading a relationship that a direct unit test supplied as an already populated struct. Neither difference appears in a test which compares only the final price.

The rating engine’s migration checks therefore needed more than translated assertions. They needed executable observations from PHP, a clear point at which to compare them with Go, and enough detail to explain a disagreement. Otherwise, “the implementations match” could mean little more than “both tests contain the same assumption.”

The test-inventory chapter covered how the large PHP suite became identifiable migration cases. This chapter follows the next step: running both implementations, comparing what they actually do, and checking whether the comparison itself is trustworthy.

A saved response and a running oracle answer different questions

One migration test reads a saved PHP response for the resource-schema catalog. It checks the source revision attached to the fixture, its 152 entries, then runs the Go HTTP handler against a freshly migrated test database. It compares status, content type and the complete decoded document, including list order. Repeated resource types remain separate entries; reducing the result to a map keyed by resource type would lose part of that contract.

That is useful regression coverage. It is also a comparison with a saved observation, not a fresh execution of PHP. If the fixture was captured from the wrong source or edited to fit the Go result, stamping it with the right revision wouldn’t repair it. The fixture’s provenance still has to be checked.

Other tests execute PHP during the Go test. The exception-resolver test, for example, exports the relevant classes and lockfile from the frozen source revision. It doesn’t use whatever application code happens to be in the PHP working directory. These are the actual archive and extraction operations, with error handling omitted:

archive, err := exec.Command(
    "git", "-C", sourceRoot, "archive", revision,
    "composer.lock",
    "src/SharedKernel/Domain/Errors",
    "src/SharedKernel/Presentation/Exceptions",
).Output()

exported := t.TempDir()
unpack := exec.Command("tar", "-x", "-C", exported)
unpack.Stdin = bytes.NewReader(archive)
if output, err := unpack.CombinedOutput(); err != nil {
    t.Fatalf("export: %v: %s", err, output)
}

The PHP runner loads application classes from that export. It reuses installed dependencies, but first compares the installed version and source reference of Laravel and Symfony’s HTTP kernel with the frozen lockfile. Its Composer loader keeps vendor mappings and replaces application mappings with the exported source. Loading the ordinary application autoloader without that separation would make it far too easy to test today’s PHP code while claiming to test the frozen application.

The Go test then sends one JSON array of inputs to the PHP subprocess. Each input also has a corresponding Go exception. The subprocess returns three observations per input: the direct resolver result, the result after Laravel’s exception preparation, and whether Laravel would report the exception. The test checks the output count before comparing those observations.

Saved observations are cheaper and easier to run everywhere. Native execution is more useful when the uncertainty lives in the source runtime or framework. I wouldn’t replace every small regression fixture with a second running application. I also wouldn’t accept a hand-written fixture as the answer to “what does Laravel actually do here?”

The exception changes before the rendering callback

Consider an authorization exception with a custom message:

$exception = new AuthorizationException(
    'You cannot access this resource.',
);

Calling the application’s resolver directly preserves that message. Through Laravel’s rendering path, the resolver receives an AccessDeniedHttpException instead. The application’s branch for that type uses its generic authorization message. The failure code is unchanged, but the detail is different.

The framework’s preparation also distinguishes authorization exceptions with an explicit HTTP status. These are the relevant branches of its match:

$e instanceof AuthorizationException && $e->hasStatus()
    => new HttpException(
        $e->status(),
        $e->response()?->message()
            ?: (Response::$statusTexts[$e->status()]
                ?? 'Whoops, looks like something went wrong.'),
        $e,
    ),
$e instanceof AuthorizationException && ! $e->hasStatus()
    => new AccessDeniedHttpException($e->getMessage(), $e),

With an explicit status of 404, preparation produces a generic HttpException. The application’s resolver doesn’t recognize that class and returns null. It would have recognized the original authorization exception. Testing the resolver alone misses the difference entirely.

The native runner observes preparation through the framework’s public rendering method. A rendering callback captures the application resolver’s result:

$handler->renderable(
    static fn (Throwable $exception)
        => new JsonResponse([
            'failure' => $serialize($resolver->resolve($exception)),
        ]),
);

$prepared = json_decode(
    $handler->render(
        Request::create('/api/native-exception'),
        $exception,
    )->getContent(),
    flags: JSON_THROW_ON_ERROR,
);

That callback is deliberately an observation point. It isn’t the application’s complete error-response pipeline. Its JSON response exposes what the resolver saw after preparation, without pretending to verify the route’s eventual HTTP status, middleware or fallback response.

For the test’s separate status-bearing “concealed authorization” fixture, the native observation is:

{
  "failure": {
    "FailureCode": "ACTOR_UNAUTHORIZED",
    "Detail": "concealed authorization",
    "ErrorMeta": null,
    "Headers": {}
  },
  "prepared_failure": null,
  "report": false
}

Reporting is a separate observation because rendering a reasonable response doesn’t establish whether an error disappears from operational reporting. Laravel excludes these authorization exceptions from reporting; an unknown runtime exception in the same test remains reportable. Returning null from the resolver is therefore not enough information to describe either case.

The Go adapter models the preparation boundary explicitly. A status-bearing authorization error produces no recognized prepared failure; one without a status becomes the access-denied representation. It doesn’t need a miniature Laravel exception hierarchy. It needs to preserve what the application observes at that boundary.

Two correct-looking projections can perform different work

The database comparison goes further than feeding two functions the same map. It creates a disposable PostgreSQL database, applies the target migrations, and inserts a published ruleset, its pricing profile and a lane catalog. A lane describes an origin/destination combination used while preparing pricing data. Both implementations read that same database.

The fixture inserts the second lane row first. Its sequence is 20; the row inserted afterward has sequence 10. The expected business order is 10, then 20. An implementation which relies on insertion order can therefore produce the wrong projection even though it fetched all the right rows.

The PHP exporter runs the actual published-source compiler. For each variant with and without general rules, it returns the pricing profile, its hash, executed queries, and whether the lane parent and rows became loaded. The Go test runs its graph reader and compiler, then compares those observations. This is part of the actual comparison:

if !reflect.DeepEqual(profile, php[index].Profile) ||
    projection.RulesetHash() != php[index].Hash ||
    !php[index].ParentLoaded || !php[index].RowsLoaded {
    t.Fatalf("variant %t differs from native PHP", includeRules)
}

rows := graph.(*ratingdomain.Ruleset).
    PricingProfile.PricingLaneCatalog.Rows
if len(rows) != 2 || rows[0].Sequence != 10 ||
    rows[1].Sequence != 20 {
    t.Fatalf("business row order: %#v", rows)
}

The query comparison is narrower than all SQL performed by the request. It compares the lane queries during compilation, replacing PHP’s placeholder spelling with Go’s equivalent. It expects the parent lookup followed by the ordered rows lookup. The published-graph setup happens before that observation window on the Go side.

This caught an important distinction in the implementation model. The source’s active graph eagerly loads many pricing relationships, but not this lane relationship. Accessing the lane parent in the compiler loads it; accessing its rows later issues another query. Treating every missing slice as an empty relationship would quietly remove that work.

The Go model therefore distinguishes “not loaded” from “loaded and empty.” The loader marks the parent loaded after either finding it or establishing that it doesn’t exist. When it has to query the rows, it sets their loaded flag only after the query succeeds. Those flags aren’t decorative ORM nostalgia. They let the adapter preserve the source’s deferred work and failure behavior without introducing Eloquent into Go.

Comparing every query forever would be a bad default. A different query plan can be perfectly compatible, and a separate optimization could deliberately change the loading strategy. Here the port was preserving an already tuned application. Query order and loaded state made the claimed equivalence inspectable; a faster redesign would need its own comparison and approval.

Break two things to find out which one fails first

Happy-path profile equality still leaves a hole. Suppose the lane catalog’s stored state is invalid and its rows table is unavailable. Does compilation reject the state, or does it attempt the rows query first?

The PHP mapping answers that through evaluation order. It reads the state before accessing the rows. These are selected members of the actual lane projection:

'state' => $laneCatalog->state,
'rows' => array_map(
    static fn (PricingLaneCatalogRow $row): array => [
        'row_id' => self::artifactId($row->getKey()),
        'row_code' => $row->row_code,
        'sequence' => $row->sequence,
        'lane_code' => $row->lane_code,
        // Other lane fields omitted.
    ],
    $laneCatalog->rows->all(),
),

The state has an enum cast. For an invalid stored value, that access raises a ValueError before the rows relationship is touched. The test makes the second failure real by temporarily renaming the rows table inside its disposable database. It then checks both the error and the partial work: parent loaded, rows still unloaded, one lane query rather than two.

With a valid state and the same missing rows table, compilation reaches the rows query and raises a database exception instead. The parent remains loaded, but the failed rows read must not mark the rows loaded. Returning an empty list would hide the failed read and create a plausible but incomplete projection.

The corresponding Go mapper preserves that order:

stateValue, err := rulesetRuntimeProjectionPricingArtifactState(
    catalog.State, catalog.StateAttributePresent,
)
if err != nil {
    return nil, err
}
if mapper.laneRelations != nil {
    if err := mapper.laneRelations.LoadLaneCatalogRows(
        ctx, catalog,
    ); err != nil {
        return nil, err
    }
}

It would be tempting to turn this into a universal rule: validate state before loading rows. That would be wrong for other source paths. A separate native comparison covers eagerly loaded accessorial, adjustment and chargeable-weight relationships. There, published-graph loading reaches the missing rows table before a later consumer casts the invalid parent state. The observed error is PostgreSQL’s 42P01, undefined table, not the enum error.

The two orders are different because the two application paths are different:

Deferred lane compilation:
parent lookup -> state cast -> ordered rows lookup -> projection

Eager published-graph loading:
parent lookup -> rows lookup -> return graph -> later state access

These are fault-injection tests, not evidence that normal authoring accepts invalid states or that a customer experienced either failure. Their value is that they expose partial work and precedence which a fully populated unit-test fixture conceals. If the migration deliberately changed that contract, the tests would need to describe the approved change rather than force the old order back into place.

A comparison can erase the disagreement

There was another awkward detail in the checking code. Some comparisons decode JSON into Go’s any and use reflect.DeepEqual. That ignores object-key order while preserving array order, which is often useful. It also decodes JSON numbers as float64.

A small probe shows why that qualification matters:

left := []byte(`{"sequence":9007199254740992}`)
right := []byte(`{"sequence":9007199254740993}`)
var a, b any
json.Unmarshal(left, &a)
json.Unmarshal(right, &b)
fmt.Println(reflect.DeepEqual(a, b)) // true

The adjacent integers have become the same floating-point value. Both documents parse, both test branches run, and equality still gives the wrong answer for an exact-integer contract. I don’t want the test harness quietly rounding the thing it is supposed to compare.

The existing exception comparison uses that decoding pattern, including for some large retry-header cases. That limits what its pass establishes. It can compare the ordinary authorization details and reporting decisions above, but it isn’t sufficient evidence for exact large-integer equality. This is a limitation in the comparator, not a demonstrated incorrect price or a reason to reproduce every PHP integer quirk.

Using typed integer fields, or a decoder with UseNumber() on both sides, keeps those two values distinguishable. A number-preserving version of the same probe returns false. That still leaves a contract decision: should 1 and 1.0 count as equivalent numbers, or does the serialized form matter? Sorting every array or converting every number to a string isn’t a sensible answer. Those transformations can remove precisely the difference under investigation.

Those selected native checks passed in the version examined here, while the port was still in progress. The number-comparison limit was unresolved in that version; green surrounding tests didn’t make it disappear.

The application-parity correction sets the right stopping point. A cross-language test should tell me which application behavior differed, where it differed, and why it matters. If it cannot do that—or if its own normalization hides the difference—another green test isn’t much comfort.