For applications
A plain Laravel app — no Roundly package, no base-class change — gets the app-usable subset through the Pest expectations or the static Assert, straight from its existing suite:
use RoundlyConsulting\Testing\Assert;
it('has a runnable migration order', function (): void {
expect(database_path('migrations'))->toHaveRunnableMigrationOrder();
});
it('reads every services key it relies on from the shipped config', function (): void {
// Forward-only: an app's config legitimately carries keys read by vendor packages.
expect(config_path('services.php'))->toSatisfyConfigContract(app_path(), ['reverse' => false]);
});
it('does not leak credentials through artisan about', function (): void {
// Only a secret that is set can be looked for — a null or empty entry throws.
config(['services.stripe.secret' => 'sk_test_do_not_render_me']);
expect('environment')->toLeakNoSecrets(
secrets: [config('app.key'), config('services.stripe.secret')],
mustRender: ['Application Name'],
);
});What applies to apps
- The migration-order pin, the real-engine runner and its negative control.
- The config contract in forward-only mode — reverse is opt-in.
- The secret-safe about capture.
- The model-swap proof — apps consume config-swappable vendor models too, so you can prove a vendor package really listens.
- The lock recorders and DriverMatrix.
- Every architecture preset. runtimeRequireIsWhitelisted encodes the Roundly whitelist, but $alsoAllow makes even that usable.
Architecture presets in an app
noDebuggingLeftovers scans <cwd>/src by default, so point it at app/ in an application:
<?php
declare(strict_types=1);
use RoundlyConsulting\Testing\Arch\ArchPresets;
// tests/ArchTest.php in a Laravel application
ArchPresets::strictTypes('App');
ArchPresets::noDebuggingLeftovers(srcDir: __DIR__.'/../app'); // the default scans <cwd>/srcPlain PHPUnit: the static Assert
Every expectation has a static mirror on RoundlyConsulting\Testing\Assert for class-based tests — toBeSwappableVia and the facade contract included. Both delegate to the same implementation, so the check is identical however you reach it:
| Pest expectation | Static mirror |
|---|---|
toHaveRunnableMigrationOrder | Assert::migrationsRunInDependencyOrder($dir, $expectedForeignKeys, $tableResolvers, $externalTables) |
toApplyOnConnection | Assert::migrationsApplyOnConnection($dir, $connection, $expectedMigrations) |
toRejectBrokenOrderOnConnection | Assert::brokenOrderIsRejectedOnConnection($dir, $reorder, $connection) |
toNotAutoLoadMigrations | Assert::doesNotAutoLoadMigrations($providerClass, $migrationsDir) |
toPublishMigrationsTimestamped | Assert::publishesMigrationsTimestamped($providerClass, $tag, $count) |
toSatisfyConfigContract | Assert::configContract($configPath, $srcDirs, $prefix, $options) |
toLeakNoSecrets | Assert::aboutSectionLeaksNoSecrets($section, $secrets, $mustRender) |
toHonourModelSwap | Assert::modelSwapHonoured($configKey, $subclass, $exercise, $expectsCreation) |
toBeSwappableVia | Assert::modelIsSwappableVia($model, $configKey) |
toDocumentItsRoot | Assert::facadeDocumentsItsRoot($facade, $except) |
toBeFakeable | Assert::facadeIsFakeable($facade) |
toReachEveryAction | Assert::facadeReachesEveryAction($facade, $actionsDir, $except, $via) |
use PHPUnit\Framework\Attributes\Test;
use RoundlyConsulting\Testing\Assert;
use Tests\TestCase;
final class SchemaTest extends TestCase
{
#[Test]
public function migrations_run_in_dependency_order(): void
{
Assert::migrationsRunInDependencyOrder(database_path('migrations'));
}
#[Test]
public function the_services_config_ships_every_key_the_app_reads(): void
{
Assert::configContract(config_path('services.php'), app_path(), options: ['reverse' => false]);
}
}Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.