Starter kits that do not paint you into a corner

Test a Laravel starter kit by keeping one workspace operation consistent across form and JSON requests, including permission denial and input normalization.
Test the first workflow that differs from the scaffold
A Laravel team choosing a starter kit needs to know what happens when its account workflow stops matching the generated screens. My recommendation is to implement one small product operation through two callers before committing to the scaffold. Creating a workspace is enough to expose duplicated validation, missing permission checks and assumptions about incoming input.
Laravel 12 starter kits provide application files you can customize and use Fortify for their default authentication features. A kit is optional. Generated files become part of your application; record your changes so dependency upgrades can be reviewed against them. The useful evaluation is whether your team can change a workflow without silently changing its rules.
The reproducible example here uses Laravel 12.69.3, PHP 8.3.35 and SQLite 3.53.4. It sends form and JSON requests through the real HTTP kernel to one create operation. It does not install a generated frontend kit, so it cannot rank React, Vue or Livewire usability. Use it to evaluate the server boundary you would keep while changing those screens.
Make both callers use the same operation
The fixture has POST /workspaces and POST /api/workspaces. Each controller passes the authenticated actor and the submitted name to CreateWorkspace. The form controller redirects to the created workspace with status 303. The JSON controller returns its ID and name with status 201. Only response formatting differs.
The gate runs before validation and insertion. Its decision comes from the server-side actor, so sending can_create=true in the request cannot grant permission. The created_by value also comes from that actor; a submitted created_by=999 cannot select another owner. Laravel turns a failed Gate authorization into a 403 response.
This complete action is executed inside the downloadable application. Its imports, gate definition, routes, database setup and input rule are included there. Copying this file alone does not install a workspace feature.
<?php
namespace App\Actions;
use App\Domain\Workspaces\WorkspaceName;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
final class CreateWorkspace
{
public function execute(Authenticatable $actor, mixed $input): array
{
Gate::forUser($actor)->authorize('create-workspace');
if (!is_string($input)) {
throw ValidationException::withMessages(['name' => WorkspaceName::MESSAGE]);
}
try {
$name = WorkspaceName::normalize($input);
} catch (InvalidArgumentException $error) {
throw ValidationException::withMessages(['name' => $error->getMessage()]);
}
$id = DB::transaction(fn () => DB::table('workspaces')->insertGetId([
'name' => $name, 'created_by' => $actor->getAuthIdentifier(),
]));
return ['id' => $id, 'name' => $name];
}
}
Check what reaches the rule after middleware
This demonstration accepts a name with 3–60 ASCII letters, digits, spaces or hyphens, starting with a letter or digit. Outer ASCII spaces are removed. Café and other non-ASCII names are rejected by design. That restriction makes the test contract explicit; it is unsuitable for an international display-name field.
The rule uses trim($input, ' ') rather than default trim($input). In the executed test, the default form removes a trailing tab and accepts a value the contract says to reject. A pure-rule test catches that change, but it cannot establish what a controller receives.
Laravel normally applies TrimStrings and ConvertEmptyStringsToNull before the controller. The fixture skips TrimStrings for these two paths, allowing the shared rule to reject the original tab. Restoring global trimming makes the HTTP rejection check fail even though the pure rule is unchanged. Empty-string conversion remains enabled; the action rejects null as a non-string input.
In a real product, decide which characters to normalize before choosing validation rules. If edge tabs should be treated as whitespace, allow that behavior and test the normalized result. If they must be rejected, make sure every caller preserves them until validation. A shared class cannot undo changes made earlier in the request.
Verify responses and writes, then break them deliberately
The normal fixture passes 74 named assertions. These cover accepted and rejected names, denied and guest requests, successful writes and field errors through both transports. For each invalid form request it checks a 302 redirect, the name error stored in the session and an unchanged row count. For JSON it checks status 422, the same field message and no inserted row.
The five negative controls change actual source files: remove the gate; insert a row before denial; make the JSON controller write directly; restore default PHP trim; restore Laravel global trimming. Each must fail its intended assertion after exactly the expected preceding checks. A syntax error or unrelated crash is rejected as invalid evidence.
The API-bypass control deliberately retains its permission check. It still fails because it stores the padded name without normalization. This distinguishes “the API has authorization” from “both callers enforce the same operation.” The write-before-denial control catches an even subtler defect: the response is correctly forbidden, but a row has already been inserted.
Download the complete fixture and run python3 frontend/content-quality/fixtures/workspace-boundary/verify.py results.json from its root. Docker and host Python are required. Locked dependencies are retrieved during the image build; the six application runs have no network and use an unprivileged user with a read-only root. The output records source hashes, observed versions and intended failure identities.
Keep the untested account behavior on the evaluation list
The driver supplies synthetic users to the session guard before each request. It tests the resulting authorization decisions, not a login exchange. The JSON route uses that same guard, not bearer tokens. Testing mode skips CSRF checks; array sessions do not demonstrate cookie persistence or session expiration. There is no Fortify or browser test in this fixture.
The database is an in-memory SQLite table with no membership model, unique-name constraint or quota. The transaction wrapper is not evidence of rollback recovery, concurrent-write safety or idempotent retries. Add those rules and their failure tests when they become part of the product contract. In particular, a name-availability query alone cannot reserve a name against another writer; use a database constraint when uniqueness is required.
Before adopting a kit, run your chosen generated UI through the same successful, invalid and denied cases, then test its actual login, expired-session and CSRF behavior. Keep a list of modified auth files and tests alongside the dependency lock. My recommendation is to choose the kit your team can trace and change under those conditions. This fixture gives you one preserved operation to start that evaluation; it does not make a later framework migration free.
Reproduce the examples
Download the locked Laravel application, exact action, source-bound results and five deliberate failure checks. Run python3 frontend/content-quality/fixtures/workspace-boundary/verify.py results.json from the extracted root with Docker available. Synthetic authenticated context; no generated UI, login exchange or CSRF verification.
Download the source and tests (.zip)
