Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Laravel security best practices — authentication, authorization, Eloquent safety, CSRF, XSS prevention, API security, and secure deployment configurations.
.claude/skills/laravel-security/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-05 | ✗→✓ | ▲ Improved | — | — |
| case-10 | ✗→✓ | ▲ Improved | — | — |
| case-01 | ✗→✓ | ▲ Improved | — | — |
| case-07 | ✗→✓ | ▲ Improved | — | — |
| case-11 | ✓→✓ | = Same ✓ | — | — |
Comprehensive security guidelines for Laravel applications to protect against common vulnerabilities.
php// config/app.php 'env' => env('APP_ENV', 'production'), 'debug' => (bool) env('APP_DEBUG', false), // CRITICAL: Never true in production 'key' => env('APP_KEY'), // Must be set: php artisan key:generate // config/session.php 'secure' => env('SESSION_SECURE_COOKIE', true), 'http_only' => true, 'same_site' => 'lax', // Verify APP_KEY is set at boot // bootstrap/app.php or a service provider if (empty(config('app.key'))) { throw new RuntimeException('APP_KEY is not set. Run: php artisan key:generate'); }
bash# NEVER commit .env to version control # .gitignore already includes .env by default # Use .env.example with placeholders instead DB_PASSWORD= APP_KEY= SANCTUM_TOKEN_PREFIX= # Validate required variables at boot // In AppServiceProvider::boot() $requiredKeys = ['app.key', 'database.connections.mysql.database', 'database.connections.mysql.username']; foreach ($requiredKeys as $key) { if (empty(config($key))) { throw new RuntimeException("Missing required config key: {$key}"); } }
php// AppServiceProvider::boot() or middleware if (app()->environment('production')) { URL::forceScheme('https'); request()->server->set('HTTPS', 'on'); } // config/app.php for trusted proxies (load balancers) // Use specific IP ranges — * trusts all, allowing X-Forwarded-* spoofing // AWS: '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16' 'trusted_proxies' => ['10.0.0.0/8', '172.16.0.0/12'], // Force HTTPS in production via middleware // app/Http/Middleware/ForceHttps.php public function handle($request, Closure $next) { if (!$request->secure() && app()->environment('production')) { return redirect()->secure($request->getRequestUri()); } return $next($request); }
php// config/sanctum.php 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s', 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : '' ))); 'expiration' => 60 * 24, // Token expiration in minutes (null = never) 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), // Issuing tokens with abilities $token = $user->createToken('api-token', ['read', 'write'])->plainTextToken; // Validate abilities on routes Route::middleware('auth:sanctum')->group(function () { Route::get('/orders', function () { // User must have 'read' ability abort_unless(Auth::user()->tokenCan('read'), 403); // ... })->middleware('abilities:read'); Route::post('/orders', function () { // User must have 'write' ability abort_unless(Auth::user()->tokenCan('write'), 403); // ... })->middleware('abilities:write'); });
php// config/hashing.php // Default is bcrypt. Argon2id is stronger. 'bcrypt' => [ 'rounds' => env('BCRYPT_ROUNDS', 12), // Increase for stronger hashing ], 'argon' => [ 'memory' => 65536, 'threads' => 4, 'time' => 4, ], // Password validation in RegisterRequest public function rules(): array { return [ 'password' => [ 'required', 'confirmed', Password::min(12) ->letters() ->mixedCase() ->numbers() ->symbols() ->uncompromised(), // Checks haveibeenpwned ], ]; } // Rate limit login attempts // App\Http\Controllers\Auth\AuthenticatedSessionController protected function authenticated(Request $request, $user) { if ($user->wasRecentlyLockedOut()) { // Notify user of suspicious login $user->notify(new SuspiciousLoginNotification($request->ip())); } }
php// config/session.php 'driver' => env('SESSION_DRIVER', 'database'), // database/redis > file 'lifetime' => env('SESSION_LIFETIME', 120), 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), 'encrypt' => env('SESSION_ENCRYPT', false), // Regenerate session on login // App\Http\Controllers\Auth\AuthenticatedSessionController public function store(LoginRequest $request): RedirectResponse { $request->authenticate(); $request->session()->regenerate(); // CRITICAL: prevents session fixation return redirect()->intended(RouteServiceProvider::HOME); } // Invalidate session on logout public function destroy(Request $request): RedirectResponse { Auth::guard('web')->logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return redirect('/'); }
php// App\Providers\AuthServiceProvider use App\Models\Post; use App\Models\User; use Illuminate\Support\Facades\Gate; public function boot(): void { Gate::define('update-post', function (User $user, Post $post): bool { return $user->id === $post->user_id; }); Gate::define('publish-post', function (User $user): bool { return $user->role === 'editor' || $user->role === 'admin'; }); // Using before() for super-admin override Gate::before(function (User $user, string $ability): ?bool { if ($user->role === 'super-admin') { return true; // Grants all abilities } return null; // Fall through to normal checks }); } // Usage in controllers public function update(Request $request, Post $post): RedirectResponse { Gate::authorize('update-post', $post); // Or: $this->authorize('update-post', $post); // Or: abort_unless(Auth::user()->can('update-post', $post), 403); // ... }
php// App\Policies\PostPolicy class PostPolicy { use HandlesAuthorization; public function viewAny(?User $user): bool { return true; // Public listing } public function view(?User $user, Post $post): bool { return $post->is_published || ($user && $user->id === $post->user_id); } public function create(User $user): bool { return $user->hasVerifiedEmail(); // Must verify email first } public function update(User $user, Post $post): bool { return $user->id === $post->user_id; } public function delete(User $user, Post $post): bool { return $user->id === $post->user_id && $post->created_at->diffInDays(now()) <= 30; } public function restore(User $user, Post $post): bool { return $user->role === 'admin'; } public function forceDelete(User $user, Post $post): bool { return $user->role === 'super-admin'; } } // Register in AuthServiceProvider protected $policies = [ Post::class => PostPolicy::class, ]; // Controller usage public function show(Post $post): View { $this->authorize('view', $post); return view('posts.show', compact('post')); } // Blade usage @can('update', $post) <a href="{{ route('posts.edit', $post) }}">Edit</a> @endcan @cannot('update', $post) <span>You cannot edit this post</span> @endcannot
php// Using middleware in routes Route::put('/posts/{post}', [PostController::class, 'update']) ->middleware('can:update,post'); Route::get('/posts/create', [PostController::class, 'create']) ->middleware('can:create,App\Models\Post'); // Custom authorization middleware // app/Http/Middleware/CheckRole.php class CheckRole { public function handle(Request $request, Closure $next, string $role): mixed { if (!$request->user() || $request->user()->role !== $role) { abort(403, 'Unauthorized. This area requires role: ' . $role); } return $next($request); } } // Register in Kernel protected $routeMiddleware = [ 'role' => \App\Http\Middleware\CheckRole::class, ]; // Route usage Route::middleware(['auth', 'role:admin'])->group(function () { Route::get('/admin', [AdminController::class, 'index']); });
php// BAD: $guarded = [] allows ALL columns to be mass-assigned // NEVER use $guarded = [] in production // GOOD: Whitelist fillable attributes final class User extends Authenticatable { protected $fillable = [ 'name', 'email', 'phone', 'avatar', ]; // NEVER add 'role', 'is_admin', 'is_verified' here } // GOOD: Explicitly control which fields can be filled in requests public function store(StoreUserRequest $request): RedirectResponse { $user = User::create($request->safe()->only([ 'name', 'email', 'phone', 'avatar' ])); // $request->safe() uses validated data only // $request->only() is NOT safe on its own without validation rules } // BAD: Creating a user with request data directly User::create($request->all()); // VULNERABLE to mass assignment! // BETTER: Use DTOs for creation $user = User::create($request->validated()); // Only validated fields
php// GOOD: Eloquent automatically parameterizes queries User::where('email', $userInput)->first(); User::whereRaw('email = ?', [$userInput])->first(); // GOOD: Query Builder also parameterizes DB::table('users')->where('email', $userInput)->first(); DB::select('SELECT * FROM users WHERE email = ?', [$userInput]); // BAD: Raw string interpolation DB::select("SELECT * FROM users WHERE email = '{$userInput}'"); // VULNERABLE! User::whereRaw("email = '{$userInput}'")->first(); // VULNERABLE! // BAD: whereRaw/orderByRaw with unescaped input User::orderByRaw($userInput); // VULNERABLE! User::groupByRaw($userInput); // VULNERABLE! // BAD: DB::statement with concatenation DB::statement("INSERT INTO users (email) VALUES ('{$userInput}')"); // VULNERABLE!
phpfinal class User extends Authenticatable { protected $casts = [ 'email_verified_at' => 'datetime', 'is_admin' => 'boolean', // Cast to boolean prevents string injection 'settings' => 'array', // Automatically json_encode/json_decode 'metadata' => 'encrypted:array', // Laravel 11+ encrypted casting 'password' => 'hashed', // Laravel 10+ auto-hashes on set ]; }
phpfinal class User extends Authenticatable { // Hide sensitive attributes from JSON/API responses protected $hidden = [ 'password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes', ]; // Append only safe computed attributes protected $appends = ['full_name']; // safe // NEVER append sensitive computed data } final class Post extends Model { // Global scope to filter soft deleted records use SoftDeletes; // Prevent N+1 by restricting lazy loading (optional strict mode) // AppServiceProvider::boot() // Model::preventLazyLoading(!app()->isProduction()); }
php// Laravel CSRF is enabled by default via VerifyCsrfToken middleware // app/Http/Kernel.php (protected $middlewareGroups['web']) // All POST/PUT/PATCH/DELETE forms must include @csrf <form method="POST" action="/posts"> @csrf <input type="text" name="title"> <button type="submit">Create</button> </form>
php// app/Http/Middleware/VerifyCsrfToken.php class VerifyCsrfToken extends Middleware { // Only exclude routes that have external CSRF protection (webhooks, etc.) protected $except = [ 'stripe/*', // Stripe webhooks use their own signature verification // Avoid blanket 'api/*' — stateful Sanctum routes need CSRF. // Exclude only specific stateless webhook/endpoint routes. ]; }
html<meta name="csrf-token" content="{{ csrf_token() }}"> <script> // Axios example (Laravel ships with Axios) axios.defaults.headers.common['X-CSRF-TOKEN'] = document.querySelector( 'meta[name="csrf-token"]' ).getAttribute('content'); // Fetch example fetch('/posts', { method: 'POST', headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), 'Content-Type': 'application/json', }, body: JSON.stringify(data), }); </script>
blade{{-- SAFE: Auto-escaped by Blade --}} {{ $userInput }} {{-- DANGEROUS: Raw output — NEVER use with user input --}} {!! $userInput !!} {{-- SAFE: Only use {!! !!} with trusted content you control --}} {!! $trustedHtmlFromYourServer !!} {{-- GOOD: Use specific escaping directives --}} @js($data) {{-- JSON encode for JavaScript --}} @json($data) {{-- JSON encode in templates --}} {{-- BAD: Direct user input in raw HTML --}} <div>{!! $user->bio !!}</div> {{-- VULNERABLE if user provides bio --}}
php// When you must allow some HTML, use a whitelist approach use HTMLPurifier; // Requires: composer require ezyang/htmlpurifier public function sanitizeHtml(string $dirty): string { $config = \HTMLPurifier_Config::createDefault(); $config->set('HTML.Allowed', 'p,b,i,a[href],ul,ol,li,br'); $config->set('URI.AllowedSchemes', ['http', 'https', 'mailto']); $purifier = new \HTMLPurifier($config); return $purifier->purify($dirty); } // In blade: <div>{!! $sanitizedContent !!}</div> {{-- Safe after purification --}}
blade{{-- SAFE: Blade @js escapes for JavaScript context --}} <script> const user = @js($user); // JSON + escaped for JS context const settings = @json($settings); // Direct JSON encode </script> {{-- DANGEROUS: Manual JSON in JS context --}} <script> const user = {{ json_encode($user) }}; // NOT escaped for JS! </script>
php// App\Http\Middleware\SecurityHeaders.php class SecurityHeaders { public function handle(Request $request, Closure $next): mixed { $response = $next($request); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('X-Frame-Options', 'DENY'); $response->headers->set('X-XSS-Protection', '1; mode=block'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); $response->headers->set( 'Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'" ); return $response; } } // Register in kernel protected $middleware = [ \App\Http\Middleware\SecurityHeaders::class, ];
phpfinal class StorePostRequest extends FormRequest { public function authorize(): bool { return $this->user()?->can('create', Post::class) ?? false; } public function rules(): array { return [ 'title' => ['required', 'string', 'max:255', 'sanitize_html'], 'content' => ['required', 'string', 'max:10000'], 'image' => [ 'required', 'image', 'mimes:jpg,jpeg,png,gif,webp', // Whitelist specific types 'max:2048', // 2MB max ], 'tags' => ['array'], 'tags.*' => ['integer', 'exists:tags,id'], ]; } public function messages(): array { return [ 'title.max' => 'Post title must not exceed 255 characters.', 'image.max' => 'Image must be under 2MB.', ]; } // Sanitize input after validation public function validated($key = null, $default = null): mixed { $validated = parent::validated(); $validated['title'] = strip_tags($validated['title']); return $key ? ($validated[$key] ?? $default) : $validated; } }
php// app/Rules/StrongPassword.php class StrongPassword implements Rule { public function passes($attribute, $value): bool { return preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#^()_\-+=])[A-Za-z\d@$!%*?&#^()_\-+=]{12,}$/', $value); } public function message(): string { return 'The :attribute must be at least 12 characters with uppercase, lowercase, number, and symbol.'; } } // app/Rules/NotBlacklistedDomain.php class NotBlacklistedDomain implements Rule { private array $blacklisted = ['mailinator.com', 'guerrillamail.com']; public function passes($attribute, $value): bool { $domain = substr(strrchr($value, '@'), 1); return !in_array(strtolower($domain), $this->blacklisted); } public function message(): string { return 'Email from disposable domains is not allowed.'; } }
php// App/Providers/RouteServiceProvider protected function configureRateLimiting(): void { RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); RateLimiter::for('auth', function (Request $request) { return Limit::perMinute(5)->by($request->ip()) ->response(function () { return response()->json([ 'message' => 'Too many login attempts. Try again in 1 minute.', ], 429); }); }); RateLimiter::for('uploads', function (Request $request) { return Limit::perHour(10)->by($request->user()?->id ?? $request->ip()) ->response(function () { return response()->json([ 'message' => 'Upload limit reached. Try again later.', ], 429); }); }); } // Route usage Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () { Route::apiResource('posts', PostController::class); }); Route::post('/login', [AuthController::class, 'login']) ->middleware('throttle:auth');
php// Sanctum (recommended for most apps — simple, first-party, SPA) // config/sanctum.php 'expiration' => 60 * 24, // Tokens expire after 24 hours 'model' => User::class, // Issuing scoped tokens $token = $user->createToken('client-name', [ 'posts:read', 'posts:write', ])->plainTextToken; // Middleware scoping Route::middleware('auth:sanctum')->group(function () { Route::get('/posts', [PostController::class, 'index']) ->middleware('abilities:posts:read'); Route::post('/posts', [PostController::class, 'store']) ->middleware('abilities:posts:write'); }); // Passport (OAuth2 — for third-party clients or complex auth flows) // Install: composer require laravel/passport Passport::tokensExpireIn(now()->addDays(15)); Passport::refreshTokensExpireIn(now()->addDays(30)); Passport::personalAccessTokensExpireIn(now()->addMonths(6));
php// config/cors.php return [ 'paths' => ['api/*', 'sanctum/csrf-cookie'], 'allowed_methods' => ['*'], 'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '')), // Whitelist specific origins 'allowed_origins_patterns' => [], 'allowed_headers' => ['*'], 'exposed_headers' => ['X-Total-Count', 'X-Pagination-Page'], 'max_age' => 0, 'supports_credentials' => true, // Required for Sanctum SPA auth ]; // NEVER: Allow all origins in production unless absolutely necessary // 'allowed_origins' => ['*'], // Only for truly public APIs
phppublic function rules(): array { return [ 'document' => [ 'required', 'file', 'mimes:pdf,doc,docx,xls,xlsx', // Whitelist specific MIME types 'max:10240', // 10MB 'extensions:pdf,doc,docx,xls,xlsx', // Verify extension matches MIME ], 'avatar' => [ 'nullable', 'image', // Ensures it's a valid image 'mimes:jpg,jpeg,png,webp', 'max:2048', 'dimensions:min_width=100,min_height=100,max_width=2000,max_height=2000', ], ]; }
php// Store files outside public directory $path = $request->file('document')->store('documents', 'local'); // Never use 'public' disk for sensitive documents // Use signed URLs for temporary file access use Illuminate\Support\Facades\Storage; public function download(Request $request, string $path) { // Generate temporary signed URL (expires in 15 minutes) $url = Storage::temporaryUrl($path, now()->addMinutes(15)); // Validate user has permission $this->authorize('download', $path); return redirect($url); } // Storage configuration for cloud with encryption // config/filesystems.php 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 'throw' => false, 'server_side_encryption' => 'AES256', // Encrypt at rest ],
bash# Always audit dependencies in CI composer audit # Pin major versions in composer.json "laravel/framework": "^11.0", "spatie/laravel-permission": "^6.0" # Check for abandoned packages composer why-not # Keep lock file in version control (it pins exact versions) # Run `composer update` deliberately, never in CI/CD
bash# .env file (NEVER commit) # .gitignore includes .env by default APP_KEY=base64:abc123... DB_PASSWORD=secure_password STRIPE_KEY=sk_live_... SANCTUM_TOKEN_PREFIX=myapp_ # For production: Use a secret manager # Deploy with: env $(aws secretsmanager get-secret-value --secret-id prod/db | jq ...) php artisan serve # Validate secrets at boot (AppServiceProvider::boot) $secrets = ['services.stripe.key', 'services.stripe.webhook_secret']; foreach ($secrets as $key) { if (empty(config($key))) { Log::critical("Missing secret: {$key}"); } }
php// Define a named rate limiter (typically in AppServiceProvider::boot()) RateLimiter::for('payments', fn () => Limit::perMinute(5));
php// Encrypt sensitive job data by implementing the interface final class ProcessPaymentJob implements ShouldQueue, ShouldBeEncrypted { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function __construct( private readonly string $paymentIntentId, // Public IDs are fine private readonly string $cardFingerprint, // Encrypted via ShouldBeEncrypted ) {} public function handle(): void { // Process payment } // Limit retries and delay between attempts public function retryUntil(): Carbon { return now()->addMinutes(5); } // Rate limit how many jobs of this type can run public function middleware(): array { return [ new RateLimited('payments'), ]; } }
php// config/logging.php 'channels' => [ 'security' => [ 'driver' => 'single', 'path' => storage_path('logs/security.log'), 'level' => 'warning', ], ], // Audit log helper final class SecurityLogger { public static function log(string $event, array $context = []): void { Log::channel('security')->warning($event, array_merge([ 'user_id' => Auth::id(), 'ip' => request()->ip(), 'user_agent' => request()->userAgent(), 'url' => request()->fullUrl(), 'timestamp' => now()->toIso8601String(), ], $context)); } } // Usage SecurityLogger::log('failed_login_attempt', ['email' => $email]); SecurityLogger::log('password_change'); SecurityLogger::log('role_change', ['target_user' => $targetId, 'new_role' => 'admin']); SecurityLogger::log('suspicious_activity', ['reason' => 'multiple_attempts_from_different_ips']);
| Check | Description | |-------|-------------| | APP_DEBUG=false | Never run with debug enabled in production | | APP_KEY set | Always run php artisan key:generate | | HTTPS enforced | Force HTTPS in production via middleware or proxy | | $fillable whitelisted | Never use $guarded = [] | | CSRF active | @csrf on all state-changing forms | | Sanctum/Passport configured | API authentication with token abilities/scopes | | Rate limiting applied | Throttle API and auth endpoints | | Input validation | FormRequest with specific rules, never $request->all() | | File upload restrictions | Validate MIME types, size, dimensions | | composer audit in CI | Check dependencies for known vulnerabilities | | password_hash / password_verify | Use Laravel's built-in hashing (bcrypt/Argon2) | | Session regeneration on login | Call $request->session()->regenerate() | | Security headers middleware | CSP, X-Frame-Options, X-Content-Type-Options | | Logged security events | Audit log for auth failures, role changes, suspicious activity | | .env not committed | Verify .gitignore includes .env |
laravel-patterns — Laravel architecture, routing, Eloquent, and API patternsbackend-patterns — General backend API and database patternslaravel-tdd — Laravel testing with PHPUnit and Pest| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-11 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +18 percentage points is the difference between those two pass rates over the 22 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.