Merge remote-tracking branch 'origin/next' into ghe-support-helpers

This commit is contained in:
Andras Bacsai 2026-06-15 12:55:34 +02:00
commit 22d05c78aa
165 changed files with 2900 additions and 344 deletions

View file

@ -0,0 +1,404 @@
---
name: configure-nightwatch
description: Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads.
license: MIT
metadata:
author: laravel
---
# Nightwatch Configuration Guide
This skill helps configure Laravel Nightwatch data collection to balance observability, performance, and privacy. Covers sampling strategies, filtering rules, and redaction methods across all event types.
## Documentation Reference
The [Nightwatch Documentation](https://nightwatch.laravel.com/docs) is the definitive and up-to-date source of information for all Nightwatch configuration options. This skill provides practical guidance and common patterns, but always consult the official documentation as the primary source of truth for specific details, environment variables, and API behavior. The documentation includes comprehensive coverage of:
- [Filtering and Configuration](https://nightwatch.laravel.com/docs/filtering) - Core concepts for sampling, filtering, and redaction
- Individual event type pages with specific configuration options:
- [Requests](https://nightwatch.laravel.com/docs/requests) - Request sampling, header handling, payload capture
- [Commands](https://nightwatch.laravel.com/docs/commands) - Command sampling and redaction
- [Queries](https://nightwatch.laravel.com/docs/queries) - Query filtering and redaction
- [Cache](https://nightwatch.laravel.com/docs/cache) - Cache event filtering by key or pattern
- [Jobs](https://nightwatch.laravel.com/docs/jobs) - Job filtering and sampling decoupling
- [Mail](https://nightwatch.laravel.com/docs/mail) - Mail event filtering
- [Notifications](https://nightwatch.laravel.com/docs/notifications) - Notification filtering by channel
- [Exceptions](https://nightwatch.laravel.com/docs/exceptions) - Exception sampling and throttling
- [Outgoing Requests](https://nightwatch.laravel.com/docs/outgoing-requests) - HTTP request filtering
- [reference.md](reference.md) - Quick lookup table by event type, production presets, and verification checklist
## Data Collection Flow
Nightwatch processes events through three stages:
1. **Sampling** - Controls which entry points are captured (requests, commands, scheduled tasks)
2. **Filtering** - Excludes specific events after sampling (queries, cache, mail, etc.)
3. **Redaction** - Modifies captured data to remove/obfuscate sensitive information
```
Request/Command/Scheduled Task
|
v
[Sampling?] ----NO----> Drop entire trace
| YES
v
Events generated
|
v
[Filtering?] ----YES---> Drop specific event
| NO
v
[Redaction] ----------> Store modified data
```
---
## Sampling Configuration
Sampling determines which entry points (requests, commands, scheduled tasks) trigger full trace collection. When an entry point is sampled, all related events are captured.
### Global Sample Rates
Configure via environment variables:
```bash
# Default: 100% sampling (all requests/commands captured)
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 # Recommended: 10% of requests
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 # Capture all commands
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
```
**Recommendation**: Start with `0.1` (10%) for requests in production, adjust based on volume and needs.
### Route-Based Sampling
Apply different rates to specific routes using the `Sample` middleware:
```php routes/web.php
use Illuminate\Support\Facades\Route;
use Laravel\Nightwatch\Http\Middleware\Sample;
// Sample admin routes at 100%
Route::middleware(Sample::rate(1.0))->prefix('admin')->group(function () {
// All admin routes sampled fully
});
// Sample API routes at 5%
Route::middleware(Sample::rate(0.05))->prefix('api')->group(function () {
// API routes sampled sparingly
});
// Always sample critical endpoints
Route::post('/checkout', [CheckoutController::class, 'process'])
->middleware(Sample::always());
// Never sample health checks
Route::get('/health', [HealthController::class, 'check'])
->middleware(Sample::never());
```
### Unmatched Route Sampling
Handle 404/bot traffic with reduced sampling:
```php routes/web.php
Route::fallback(fn () => abort(404))
->middleware(Sample::rate(0.01)); // 1% sampling for unmatched routes
```
### Dynamic Sampling
Sample based on runtime conditions (user role, request attributes):
```php app/Http/Middleware/SampleAdminRequests.php
use Closure;
use Illuminate\Http\Request;
use Laravel\Nightwatch\Facades\Nightwatch;
class SampleAdminRequests
{
public function handle(Request $request, Closure $next)
{
if ($request->user()?->isAdmin()) {
Nightwatch::sample(); // Always sample admin requests
}
return $next($request);
}
}
```
### Command Sampling
Exclude specific commands from sampling:
```php AppServiceProvider.php
use Illuminate\Console\Events\CommandStarting;
use Illuminate\Support\Facades\Event;
use Laravel\Nightwatch\Facades\Nightwatch;
public function boot(): void
{
Event::listen(function (CommandStarting $event) {
if (in_array($event->command, ['schedule:finish', 'horizon:snapshot'])) {
Nightwatch::dontSample();
}
});
}
```
### Vendor Commands
Nightwatch automatically ignores framework/internal commands. Opt-in to capture them:
```php
Nightwatch::captureDefaultVendorCommands();
```
---
## Filtering Configuration
Filtering excludes specific events from collection after sampling. Use filtering to reduce noise and quota usage.
### Database Queries
**Filter all queries** (disable query collection):
```bash
NIGHTWATCH_IGNORE_QUERIES=true
```
**Filter specific queries** by SQL pattern:
```php AppServiceProvider.php
use Laravel\Nightwatch\Facades\Nightwatch;
use Laravel\Nightwatch\Records\Query;
public function boot(): void
{
// Filter job table queries (PostgreSQL)
Nightwatch::rejectQueries(function (Query $query) {
return str_contains($query->sql, 'into "jobs"');
});
// Filter cache table queries (MySQL)
Nightwatch::rejectQueries(function (Query $query) {
return str_contains($query->sql, 'from `cache`')
|| str_contains($query->sql, 'into `cache`');
});
}
```
### Cache Events
**Filter all cache events**:
```bash
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
```
**Filter by cache key patterns**:
```php
Nightwatch::rejectCacheKeys([
'my-app:users', // Exact match
'/^my-app:posts:/', // Regex: starts with my-app:posts:
'/^[a-zA-Z0-9]{40}$/', // Regex: session IDs
]);
```
**Filter with callback**:
```php
use Laravel\Nightwatch\Records\CacheEvent;
Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) {
return str_starts_with($cacheEvent->key, 'temp:');
});
```
### Mail Events
**Filter all mail**:
```bash
NIGHTWATCH_IGNORE_MAIL=true
```
**Filter specific mail**:
```php
use Laravel\Nightwatch\Records\Mail;
Nightwatch::rejectMail(function (Mail $mail) {
return str_contains($mail->subject, 'Newsletter');
});
```
### Notification Events
**Filter all notifications**:
```bash
NIGHTWATCH_IGNORE_NOTIFICATIONS=true
```
**Filter by channel**:
```php
use Laravel\Nightwatch\Records\Notification;
Nightwatch::rejectNotifications(function (Notification $notification) {
return $notification->channel === 'database';
});
```
### Outgoing HTTP Requests
**Filter all outgoing requests**:
```bash
NIGHTWATCH_IGNORE_OUTGOING_REQUESTS=true
```
**Filter by URL**:
```php
use Laravel\Nightwatch\Records\OutgoingRequest;
Nightwatch::rejectOutgoingRequests(function (OutgoingRequest $request) {
return str_contains($request->url, 'analytics.example.com');
});
```
### Queued Jobs
**Filter specific jobs**:
```php
use Laravel\Nightwatch\Records\QueuedJob;
Nightwatch::rejectQueuedJobs(function (QueuedJob $job) {
return $job->name === 'App\Jobs\LowPriorityJob';
});
```
### Decoupling Job Sampling
Sample jobs independently from parent contexts:
```php
use Illuminate\Support\Facades\Queue;
public function boot(): void
{
Queue::before(fn () => Nightwatch::sample(rate: 0.5));
}
```
---
## Redaction Configuration
Redaction modifies captured data to remove or obfuscate sensitive information. Unlike filtering, redaction keeps the event but sanitizes its content.
### Request Redaction
**Redact sensitive headers** (automatically redacts: Authorization, Cookie, X-XSRF-TOKEN):
```bash
# Customize redacted headers
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-API-Key
```
**Redact request payloads** (disabled by default):
```bash
# Enable payload capture
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=true
# Customize redacted fields
NIGHTWATCH_REDACT_PAYLOAD_FIELDS=password,password_confirmation,ssn,credit_card
```
**Programmatic redaction**:
```php
use Laravel\Nightwatch\Facades\Nightwatch;
use Laravel\Nightwatch\Records\Request;
Nightwatch::redactRequests(function (Request $request) {
$request->url = str_replace('secret', '***', $request->url);
$request->ip = preg_replace('/\d+$/', '***', $request->ip);
});
```
### Query Redaction
```php
use Laravel\Nightwatch\Records\Query;
Nightwatch::redactQueries(function (Query $query) {
$query->sql = str_replace('secret_token', '***', $query->sql);
});
```
### Cache Redaction
```php
use Laravel\Nightwatch\Records\CacheEvent;
Nightwatch::redactCacheEvents(function (CacheEvent $cacheEvent) {
$cacheEvent->key = str_replace('user:', 'user:***:', $cacheEvent->key);
});
```
### Command Redaction
```php
use Laravel\Nightwatch\Records\Command;
Nightwatch::redactCommands(function (Command $command) {
$command->command = preg_replace('/--password=\S+/', '--password=***', $command->command);
});
```
### Exception Redaction
```php
use Laravel\Nightwatch\Records\Exception;
Nightwatch::redactExceptions(function (Exception $exception) {
$exception->message = str_replace('secret', '***', $exception->message);
});
```
### Mail Redaction
```php
use Laravel\Nightwatch\Records\Mail;
Nightwatch::redactMail(function (Mail $mail) {
$mail->subject = str_replace('Invoice #', 'Invoice ***', $mail->subject);
});
```
### Outgoing Request Redaction
```php
use Laravel\Nightwatch\Records\OutgoingRequest;
Nightwatch::redactOutgoingRequests(function (OutgoingRequest $outgoingRequest) {
$outgoingRequest->url = preg_replace('/api_key=\w+/', 'api_key=***', $outgoingRequest->url);
});
```

View file

@ -0,0 +1,108 @@
# Nightwatch Configuration Reference
## Configuration Summary by Event Type
| Event Type | Sampling | Filtering | Redaction |
| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------- |
| **Requests** | `NIGHTWATCH_REQUEST_SAMPLE_RATE`, Route middleware | Not applicable | Headers, payload, URL, IP |
| **Commands** | `NIGHTWATCH_COMMAND_SAMPLE_RATE`, Event listener | Not applicable | Command arguments |
| **Queries** | Parent context | `rejectQueries()`, `NIGHTWATCH_IGNORE_QUERIES` | SQL statement |
| **Cache** | Parent context | `rejectCacheKeys()`, `rejectCacheEvents()`, `NIGHTWATCH_IGNORE_CACHE_EVENTS` | Cache key |
| **Jobs** | Parent context, Queue::before | `rejectQueuedJobs()` | Not applicable |
| **Mail** | Parent context | `rejectMail()`, `NIGHTWATCH_IGNORE_MAIL` | Subject |
| **Notifications** | Parent context | `rejectNotifications()`, `NIGHTWATCH_IGNORE_NOTIFICATIONS` | Not applicable |
| **Outgoing Requests** | Parent context | `rejectOutgoingRequests()`, `NIGHTWATCH_IGNORE_OUTGOING_REQUESTS` | URL |
| **Exceptions** | `NIGHTWATCH_EXCEPTION_SAMPLE_RATE` | Not applicable | Exception message |
---
## Production Recommendations
### High-Traffic Applications
```bash
# Conservative sampling
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.01 # 1% of requests
NIGHTWATCH_COMMAND_SAMPLE_RATE=0.1 # 10% of commands
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
# Filter noisy events
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
NIGHTWATCH_IGNORE_QUERIES=true # Or filter specific queries programmatically
```
### Privacy-Conscious Applications
```bash
# Disable sensitive data collection
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=false
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN
# Or use redaction in AppServiceProvider
```
### Balanced Configuration (Recommended Start)
```bash
# Sample rates
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0
# Filter obvious noise programmatically
# Redact PII as needed
```
---
## Verification Checklist
After configuration:
- [ ] Sampling rates appropriate for traffic volume
- [ ] Noisy events filtered (cache, certain queries)
- [ ] Sensitive data redacted (PII, tokens, credentials)
- [ ] Exceptions always captured for debugging
- [ ] Test in development with `NIGHTWATCH_REQUEST_SAMPLE_RATE=1.0`
- [ ] Monitor event quota usage in Nightwatch dashboard
---
## Common Patterns
### Filter Health Checks + Reduce Sampling
```php
Route::get('/health', fn() => ['status' => 'ok'])
->middleware(Sample::never());
```
### Exclude Internal/Vendor Queries
```php
Nightwatch::rejectQueries(fn($q) =>
str_contains($q->sql, 'telescope') ||
str_contains($q->sql, 'pulse')
);
```
### Protect User Data in Cache Keys
```php
Nightwatch::redactCacheEvents(fn($e) =>
$e->key = preg_replace('/user:\d+/', 'user:***', $e->key)
);
```

View file

@ -1,6 +1,6 @@
--- ---
name: fortify-development name: fortify-development
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.' description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
license: MIT license: MIT
metadata: metadata:
author: laravel author: laravel
@ -32,6 +32,7 @@ ## Available Features
- `Features::updateProfileInformation()` - Profile updates - `Features::updateProfileInformation()` - Profile updates
- `Features::updatePasswords()` - Password changes - `Features::updatePasswords()` - Password changes
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes - `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys
> Use `search-docs` for feature configuration options and customization patterns. > Use `search-docs` for feature configuration options and customization patterns.
@ -50,6 +51,18 @@ ### Two-Factor Authentication Setup
> Use `search-docs` for TOTP implementation and recovery code handling patterns. > Use `search-docs` for TOTP implementation and recovery code handling patterns.
### Passkeys Setup
```
- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser
- [ ] Enable passkeys feature in config/fortify.php
- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable
- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion
```
> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm.
### Email Verification Setup ### Email Verification Setup
``` ```
@ -129,3 +142,10 @@ ## Key Endpoints
| 2FA Challenge | POST | `/two-factor-challenge` | | 2FA Challenge | POST | `/two-factor-challenge` |
| Get QR Code | GET | `/user/two-factor-qr-code` | | Get QR Code | GET | `/user/two-factor-qr-code` |
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | | Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
| Passkey Login Options | GET | `/passkeys/login/options` |
| Passkey Login | POST | `/passkeys/login` |
| Passkey Confirm Options| GET | `/passkeys/confirm/options` |
| Passkey Confirm | POST | `/passkeys/confirm` |
| Passkey Options | GET | `/user/passkeys/options` |
| Register Passkey | POST | `/user/passkeys` |
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |

View file

@ -94,7 +94,7 @@ ### 8. Testing Patterns → `rules/testing.md`
### 9. Queue & Job Patterns → `rules/queue-jobs.md` ### 9. Queue & Job Patterns → `rules/queue-jobs.md`
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` - `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency - `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` - Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs - `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
- Horizon for complex multi-queue scenarios - Horizon for complex multi-queue scenarios

View file

@ -82,7 +82,7 @@ ## Code to Interfaces
## Default Sort by Descending ## Default Sort by Descending
When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres. When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
Incorrect: Incorrect:
```php ```php

View file

@ -2,7 +2,7 @@ # Caching Best Practices
## Use `Cache::remember()` Instead of Manual Get/Put ## Use `Cache::remember()` Instead of Manual Get/Put
Atomic pattern prevents race conditions and removes boilerplate. Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
Incorrect: Incorrect:
```php ```php

View file

@ -2,7 +2,7 @@ # Configuration Best Practices
## `env()` Only in Config Files ## `env()` Only in Config Files
Direct `env()` calls return `null` when config is cached. Direct `env()` calls may return `null` when config is cached.
Incorrect: Incorrect:
```php ```php

View file

@ -29,7 +29,11 @@ ## Always Queue Notifications
## Use `afterCommit()` on Notifications in Transactions ## Use `afterCommit()` on Notifications in Transactions
Same race condition as events — the queued notification job may run before the transaction commits. Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
```php
$user->notify((new InvoicePaid($invoice))->afterCommit());
```
## Route Notification Channels to Dedicated Queues ## Route Notification Channels to Dedicated Queues

View file

@ -52,7 +52,7 @@ ## Use Retry with Backoff for External APIs
Only retry on specific errors: Only retry on specific errors:
```php ```php
$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { $response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
return $exception instanceof ConnectionException return $exception instanceof ConnectionException
|| ($exception instanceof RequestException && $exception->response->serverError()); || ($exception instanceof RequestException && $exception->response->serverError());
})->post('https://api.example.com/data'); })->post('https://api.example.com/data');

View file

@ -10,7 +10,7 @@ ## Use `afterCommit()` on Mailables Inside Transactions
## Use `assertQueued()` Not `assertSent()` for Queued Mailables ## Use `assertQueued()` Not `assertSent()` for Queued Mailables
`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. `Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.

View file

@ -106,25 +106,23 @@ ## `retryUntil()` Needs `$tries = 0`
```php ```php
public $tries = 0; public $tries = 0;
public function retryUntil(): DateTime public function retryUntil(): \DateTimeInterface
{ {
return now()->addHours(4); return now()->addHours(4);
} }
``` ```
## Use `WithoutOverlapping::untilProcessing()` ## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
Prevents concurrent execution while allowing new instances to queue. `ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
```php ```php
public function middleware(): array class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{ {
return [new WithoutOverlapping($this->product->id)->untilProcessing()]; // Lock releases when processing begins, not when it finishes
} }
``` ```
Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts.
## Use Horizon for Complex Queue Scenarios ## Use Horizon for Complex Queue Scenarios
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.

View file

@ -36,7 +36,8 @@ ## Use Resource Controllers
```php ```php
Route::resource('posts', PostController::class); Route::resource('posts', PostController::class);
Route::apiResource('api/posts', Api\PostController::class); // In routes/api.php — the /api prefix is applied automatically
Route::apiResource('posts', Api\PostController::class);
``` ```
## Keep Controllers Thin ## Keep Controllers Thin

View file

@ -32,7 +32,7 @@ ## Authorize Every Action
Incorrect: Incorrect:
```php ```php
public function update(Request $request, Post $post) public function update(UpdatePostRequest $request, Post $post)
{ {
$post->update($request->validated()); $post->update($request->validated());
} }
@ -90,7 +90,7 @@ ## Escape Output to Prevent XSS
## CSRF Protection ## CSRF Protection
Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
Incorrect: Incorrect:
```blade ```blade
@ -121,7 +121,7 @@ ## Rate Limit Auth and API Routes
## Validate File Uploads ## Validate File Uploads
Validate MIME type, extension, and size. Never trust client-provided filenames. Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
```php ```php
public function rules(): array public function rules(): array

View file

@ -2,7 +2,7 @@ # Testing Best Practices
## Use `LazilyRefreshDatabase` Over `RefreshDatabase` ## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites. `RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
## Use Model Assertions Over Raw Database Assertions ## Use Model Assertions Over Raw Database Assertions

View file

@ -0,0 +1,96 @@
---
name: mcp-development
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
license: MIT
metadata:
author: laravel
---
# MCP Development
## Documentation
Use `search-docs` for detailed Laravel MCP patterns and documentation.
## Basic Usage
Register MCP servers in `routes/ai.php`:
<!-- Register MCP Server -->
```php
use Laravel\Mcp\Facades\Mcp;
Mcp::web();
```
### Creating MCP Primitives
Create MCP tools, resources, prompts, and servers using artisan commands:
```bash
php artisan make:mcp-tool ToolName # Create a tool
php artisan make:mcp-resource ResourceName # Create a resource
php artisan make:mcp-prompt PromptName # Create a prompt
php artisan make:mcp-server ServerName # Create a server
```
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
### Tools
<!-- MCP Tool Example -->
```php
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Request;
use Laravel\Mcp\Server\Response;
class MyTool extends Tool
{
public function handle(Request $request): Response
{
return new Response(['result' => 'success']);
}
}
```
### Registering Primitives in a Server
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
<!-- Register Primitives in MCP Server -->
```php
use Laravel\Mcp\Server;
class AppServer extends Server
{
protected array $tools = [
\App\Mcp\Tools\MyTool::class,
];
protected array $resources = [
\App\Mcp\Resources\MyResource::class,
];
protected array $prompts = [
\App\Mcp\Prompts\MyPrompt::class,
];
}
```
## Verification
1. Check `routes/ai.php` for proper registration
2. Test tool via MCP client
## Common Pitfalls
- Running `mcp:start` command (it hangs waiting for input)
- Using HTTPS locally with Node-based MCP clients
- Not using `search-docs` for the latest MCP documentation
- Not registering MCP server routes in `routes/ai.php`
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config

View file

@ -1,6 +1,6 @@
--- ---
name: pest-testing name: pest-testing
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT license: MIT
metadata: metadata:
author: laravel author: laravel
@ -18,6 +18,12 @@ ### Creating Tests
All tests must be written using Pest. Use `php artisan make:test --pest {name}`. All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
The `{name}` argument should include only the path and test name, but should not include the test suite.
- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php`
- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php`
- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php`
- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php`
### Test Organization ### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. - Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
@ -26,6 +32,8 @@ ### Test Organization
### Basic Test Structure ### Basic Test Structure
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
<!-- Basic Pest Test Example --> <!-- Basic Pest Test Example -->
```php ```php
it('is true', function () { it('is true', function () {
@ -155,3 +163,4 @@ ## Common Pitfalls
- Forgetting datasets for repetitive validation tests - Forgetting datasets for repetitive validation tests
- Deleting tests without approval - Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests - Forgetting `assertNoJavaScriptErrors()` in browser tests
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`

View file

@ -0,0 +1,404 @@
---
name: configure-nightwatch
description: Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads.
license: MIT
metadata:
author: laravel
---
# Nightwatch Configuration Guide
This skill helps configure Laravel Nightwatch data collection to balance observability, performance, and privacy. Covers sampling strategies, filtering rules, and redaction methods across all event types.
## Documentation Reference
The [Nightwatch Documentation](https://nightwatch.laravel.com/docs) is the definitive and up-to-date source of information for all Nightwatch configuration options. This skill provides practical guidance and common patterns, but always consult the official documentation as the primary source of truth for specific details, environment variables, and API behavior. The documentation includes comprehensive coverage of:
- [Filtering and Configuration](https://nightwatch.laravel.com/docs/filtering) - Core concepts for sampling, filtering, and redaction
- Individual event type pages with specific configuration options:
- [Requests](https://nightwatch.laravel.com/docs/requests) - Request sampling, header handling, payload capture
- [Commands](https://nightwatch.laravel.com/docs/commands) - Command sampling and redaction
- [Queries](https://nightwatch.laravel.com/docs/queries) - Query filtering and redaction
- [Cache](https://nightwatch.laravel.com/docs/cache) - Cache event filtering by key or pattern
- [Jobs](https://nightwatch.laravel.com/docs/jobs) - Job filtering and sampling decoupling
- [Mail](https://nightwatch.laravel.com/docs/mail) - Mail event filtering
- [Notifications](https://nightwatch.laravel.com/docs/notifications) - Notification filtering by channel
- [Exceptions](https://nightwatch.laravel.com/docs/exceptions) - Exception sampling and throttling
- [Outgoing Requests](https://nightwatch.laravel.com/docs/outgoing-requests) - HTTP request filtering
- [reference.md](reference.md) - Quick lookup table by event type, production presets, and verification checklist
## Data Collection Flow
Nightwatch processes events through three stages:
1. **Sampling** - Controls which entry points are captured (requests, commands, scheduled tasks)
2. **Filtering** - Excludes specific events after sampling (queries, cache, mail, etc.)
3. **Redaction** - Modifies captured data to remove/obfuscate sensitive information
```
Request/Command/Scheduled Task
|
v
[Sampling?] ----NO----> Drop entire trace
| YES
v
Events generated
|
v
[Filtering?] ----YES---> Drop specific event
| NO
v
[Redaction] ----------> Store modified data
```
---
## Sampling Configuration
Sampling determines which entry points (requests, commands, scheduled tasks) trigger full trace collection. When an entry point is sampled, all related events are captured.
### Global Sample Rates
Configure via environment variables:
```bash
# Default: 100% sampling (all requests/commands captured)
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 # Recommended: 10% of requests
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 # Capture all commands
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
```
**Recommendation**: Start with `0.1` (10%) for requests in production, adjust based on volume and needs.
### Route-Based Sampling
Apply different rates to specific routes using the `Sample` middleware:
```php routes/web.php
use Illuminate\Support\Facades\Route;
use Laravel\Nightwatch\Http\Middleware\Sample;
// Sample admin routes at 100%
Route::middleware(Sample::rate(1.0))->prefix('admin')->group(function () {
// All admin routes sampled fully
});
// Sample API routes at 5%
Route::middleware(Sample::rate(0.05))->prefix('api')->group(function () {
// API routes sampled sparingly
});
// Always sample critical endpoints
Route::post('/checkout', [CheckoutController::class, 'process'])
->middleware(Sample::always());
// Never sample health checks
Route::get('/health', [HealthController::class, 'check'])
->middleware(Sample::never());
```
### Unmatched Route Sampling
Handle 404/bot traffic with reduced sampling:
```php routes/web.php
Route::fallback(fn () => abort(404))
->middleware(Sample::rate(0.01)); // 1% sampling for unmatched routes
```
### Dynamic Sampling
Sample based on runtime conditions (user role, request attributes):
```php app/Http/Middleware/SampleAdminRequests.php
use Closure;
use Illuminate\Http\Request;
use Laravel\Nightwatch\Facades\Nightwatch;
class SampleAdminRequests
{
public function handle(Request $request, Closure $next)
{
if ($request->user()?->isAdmin()) {
Nightwatch::sample(); // Always sample admin requests
}
return $next($request);
}
}
```
### Command Sampling
Exclude specific commands from sampling:
```php AppServiceProvider.php
use Illuminate\Console\Events\CommandStarting;
use Illuminate\Support\Facades\Event;
use Laravel\Nightwatch\Facades\Nightwatch;
public function boot(): void
{
Event::listen(function (CommandStarting $event) {
if (in_array($event->command, ['schedule:finish', 'horizon:snapshot'])) {
Nightwatch::dontSample();
}
});
}
```
### Vendor Commands
Nightwatch automatically ignores framework/internal commands. Opt-in to capture them:
```php
Nightwatch::captureDefaultVendorCommands();
```
---
## Filtering Configuration
Filtering excludes specific events from collection after sampling. Use filtering to reduce noise and quota usage.
### Database Queries
**Filter all queries** (disable query collection):
```bash
NIGHTWATCH_IGNORE_QUERIES=true
```
**Filter specific queries** by SQL pattern:
```php AppServiceProvider.php
use Laravel\Nightwatch\Facades\Nightwatch;
use Laravel\Nightwatch\Records\Query;
public function boot(): void
{
// Filter job table queries (PostgreSQL)
Nightwatch::rejectQueries(function (Query $query) {
return str_contains($query->sql, 'into "jobs"');
});
// Filter cache table queries (MySQL)
Nightwatch::rejectQueries(function (Query $query) {
return str_contains($query->sql, 'from `cache`')
|| str_contains($query->sql, 'into `cache`');
});
}
```
### Cache Events
**Filter all cache events**:
```bash
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
```
**Filter by cache key patterns**:
```php
Nightwatch::rejectCacheKeys([
'my-app:users', // Exact match
'/^my-app:posts:/', // Regex: starts with my-app:posts:
'/^[a-zA-Z0-9]{40}$/', // Regex: session IDs
]);
```
**Filter with callback**:
```php
use Laravel\Nightwatch\Records\CacheEvent;
Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) {
return str_starts_with($cacheEvent->key, 'temp:');
});
```
### Mail Events
**Filter all mail**:
```bash
NIGHTWATCH_IGNORE_MAIL=true
```
**Filter specific mail**:
```php
use Laravel\Nightwatch\Records\Mail;
Nightwatch::rejectMail(function (Mail $mail) {
return str_contains($mail->subject, 'Newsletter');
});
```
### Notification Events
**Filter all notifications**:
```bash
NIGHTWATCH_IGNORE_NOTIFICATIONS=true
```
**Filter by channel**:
```php
use Laravel\Nightwatch\Records\Notification;
Nightwatch::rejectNotifications(function (Notification $notification) {
return $notification->channel === 'database';
});
```
### Outgoing HTTP Requests
**Filter all outgoing requests**:
```bash
NIGHTWATCH_IGNORE_OUTGOING_REQUESTS=true
```
**Filter by URL**:
```php
use Laravel\Nightwatch\Records\OutgoingRequest;
Nightwatch::rejectOutgoingRequests(function (OutgoingRequest $request) {
return str_contains($request->url, 'analytics.example.com');
});
```
### Queued Jobs
**Filter specific jobs**:
```php
use Laravel\Nightwatch\Records\QueuedJob;
Nightwatch::rejectQueuedJobs(function (QueuedJob $job) {
return $job->name === 'App\Jobs\LowPriorityJob';
});
```
### Decoupling Job Sampling
Sample jobs independently from parent contexts:
```php
use Illuminate\Support\Facades\Queue;
public function boot(): void
{
Queue::before(fn () => Nightwatch::sample(rate: 0.5));
}
```
---
## Redaction Configuration
Redaction modifies captured data to remove or obfuscate sensitive information. Unlike filtering, redaction keeps the event but sanitizes its content.
### Request Redaction
**Redact sensitive headers** (automatically redacts: Authorization, Cookie, X-XSRF-TOKEN):
```bash
# Customize redacted headers
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-API-Key
```
**Redact request payloads** (disabled by default):
```bash
# Enable payload capture
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=true
# Customize redacted fields
NIGHTWATCH_REDACT_PAYLOAD_FIELDS=password,password_confirmation,ssn,credit_card
```
**Programmatic redaction**:
```php
use Laravel\Nightwatch\Facades\Nightwatch;
use Laravel\Nightwatch\Records\Request;
Nightwatch::redactRequests(function (Request $request) {
$request->url = str_replace('secret', '***', $request->url);
$request->ip = preg_replace('/\d+$/', '***', $request->ip);
});
```
### Query Redaction
```php
use Laravel\Nightwatch\Records\Query;
Nightwatch::redactQueries(function (Query $query) {
$query->sql = str_replace('secret_token', '***', $query->sql);
});
```
### Cache Redaction
```php
use Laravel\Nightwatch\Records\CacheEvent;
Nightwatch::redactCacheEvents(function (CacheEvent $cacheEvent) {
$cacheEvent->key = str_replace('user:', 'user:***:', $cacheEvent->key);
});
```
### Command Redaction
```php
use Laravel\Nightwatch\Records\Command;
Nightwatch::redactCommands(function (Command $command) {
$command->command = preg_replace('/--password=\S+/', '--password=***', $command->command);
});
```
### Exception Redaction
```php
use Laravel\Nightwatch\Records\Exception;
Nightwatch::redactExceptions(function (Exception $exception) {
$exception->message = str_replace('secret', '***', $exception->message);
});
```
### Mail Redaction
```php
use Laravel\Nightwatch\Records\Mail;
Nightwatch::redactMail(function (Mail $mail) {
$mail->subject = str_replace('Invoice #', 'Invoice ***', $mail->subject);
});
```
### Outgoing Request Redaction
```php
use Laravel\Nightwatch\Records\OutgoingRequest;
Nightwatch::redactOutgoingRequests(function (OutgoingRequest $outgoingRequest) {
$outgoingRequest->url = preg_replace('/api_key=\w+/', 'api_key=***', $outgoingRequest->url);
});
```

View file

@ -0,0 +1,108 @@
# Nightwatch Configuration Reference
## Configuration Summary by Event Type
| Event Type | Sampling | Filtering | Redaction |
| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------- |
| **Requests** | `NIGHTWATCH_REQUEST_SAMPLE_RATE`, Route middleware | Not applicable | Headers, payload, URL, IP |
| **Commands** | `NIGHTWATCH_COMMAND_SAMPLE_RATE`, Event listener | Not applicable | Command arguments |
| **Queries** | Parent context | `rejectQueries()`, `NIGHTWATCH_IGNORE_QUERIES` | SQL statement |
| **Cache** | Parent context | `rejectCacheKeys()`, `rejectCacheEvents()`, `NIGHTWATCH_IGNORE_CACHE_EVENTS` | Cache key |
| **Jobs** | Parent context, Queue::before | `rejectQueuedJobs()` | Not applicable |
| **Mail** | Parent context | `rejectMail()`, `NIGHTWATCH_IGNORE_MAIL` | Subject |
| **Notifications** | Parent context | `rejectNotifications()`, `NIGHTWATCH_IGNORE_NOTIFICATIONS` | Not applicable |
| **Outgoing Requests** | Parent context | `rejectOutgoingRequests()`, `NIGHTWATCH_IGNORE_OUTGOING_REQUESTS` | URL |
| **Exceptions** | `NIGHTWATCH_EXCEPTION_SAMPLE_RATE` | Not applicable | Exception message |
---
## Production Recommendations
### High-Traffic Applications
```bash
# Conservative sampling
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.01 # 1% of requests
NIGHTWATCH_COMMAND_SAMPLE_RATE=0.1 # 10% of commands
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
# Filter noisy events
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
NIGHTWATCH_IGNORE_QUERIES=true # Or filter specific queries programmatically
```
### Privacy-Conscious Applications
```bash
# Disable sensitive data collection
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=false
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN
# Or use redaction in AppServiceProvider
```
### Balanced Configuration (Recommended Start)
```bash
# Sample rates
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0
# Filter obvious noise programmatically
# Redact PII as needed
```
---
## Verification Checklist
After configuration:
- [ ] Sampling rates appropriate for traffic volume
- [ ] Noisy events filtered (cache, certain queries)
- [ ] Sensitive data redacted (PII, tokens, credentials)
- [ ] Exceptions always captured for debugging
- [ ] Test in development with `NIGHTWATCH_REQUEST_SAMPLE_RATE=1.0`
- [ ] Monitor event quota usage in Nightwatch dashboard
---
## Common Patterns
### Filter Health Checks + Reduce Sampling
```php
Route::get('/health', fn() => ['status' => 'ok'])
->middleware(Sample::never());
```
### Exclude Internal/Vendor Queries
```php
Nightwatch::rejectQueries(fn($q) =>
str_contains($q->sql, 'telescope') ||
str_contains($q->sql, 'pulse')
);
```
### Protect User Data in Cache Keys
```php
Nightwatch::redactCacheEvents(fn($e) =>
$e->key = preg_replace('/user:\d+/', 'user:***', $e->key)
);
```

View file

@ -1,6 +1,6 @@
--- ---
name: fortify-development name: fortify-development
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.' description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
license: MIT license: MIT
metadata: metadata:
author: laravel author: laravel
@ -32,6 +32,7 @@ ## Available Features
- `Features::updateProfileInformation()` - Profile updates - `Features::updateProfileInformation()` - Profile updates
- `Features::updatePasswords()` - Password changes - `Features::updatePasswords()` - Password changes
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes - `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys
> Use `search-docs` for feature configuration options and customization patterns. > Use `search-docs` for feature configuration options and customization patterns.
@ -50,6 +51,18 @@ ### Two-Factor Authentication Setup
> Use `search-docs` for TOTP implementation and recovery code handling patterns. > Use `search-docs` for TOTP implementation and recovery code handling patterns.
### Passkeys Setup
```
- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser
- [ ] Enable passkeys feature in config/fortify.php
- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable
- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion
```
> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm.
### Email Verification Setup ### Email Verification Setup
``` ```
@ -129,3 +142,10 @@ ## Key Endpoints
| 2FA Challenge | POST | `/two-factor-challenge` | | 2FA Challenge | POST | `/two-factor-challenge` |
| Get QR Code | GET | `/user/two-factor-qr-code` | | Get QR Code | GET | `/user/two-factor-qr-code` |
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | | Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
| Passkey Login Options | GET | `/passkeys/login/options` |
| Passkey Login | POST | `/passkeys/login` |
| Passkey Confirm Options| GET | `/passkeys/confirm/options` |
| Passkey Confirm | POST | `/passkeys/confirm` |
| Passkey Options | GET | `/user/passkeys/options` |
| Register Passkey | POST | `/user/passkeys` |
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |

View file

@ -94,7 +94,7 @@ ### 8. Testing Patterns → `rules/testing.md`
### 9. Queue & Job Patterns → `rules/queue-jobs.md` ### 9. Queue & Job Patterns → `rules/queue-jobs.md`
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` - `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency - `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` - Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs - `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
- Horizon for complex multi-queue scenarios - Horizon for complex multi-queue scenarios

View file

@ -82,7 +82,7 @@ ## Code to Interfaces
## Default Sort by Descending ## Default Sort by Descending
When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres. When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
Incorrect: Incorrect:
```php ```php

View file

@ -2,7 +2,7 @@ # Caching Best Practices
## Use `Cache::remember()` Instead of Manual Get/Put ## Use `Cache::remember()` Instead of Manual Get/Put
Atomic pattern prevents race conditions and removes boilerplate. Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
Incorrect: Incorrect:
```php ```php

View file

@ -2,7 +2,7 @@ # Configuration Best Practices
## `env()` Only in Config Files ## `env()` Only in Config Files
Direct `env()` calls return `null` when config is cached. Direct `env()` calls may return `null` when config is cached.
Incorrect: Incorrect:
```php ```php

View file

@ -29,7 +29,11 @@ ## Always Queue Notifications
## Use `afterCommit()` on Notifications in Transactions ## Use `afterCommit()` on Notifications in Transactions
Same race condition as events — the queued notification job may run before the transaction commits. Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
```php
$user->notify((new InvoicePaid($invoice))->afterCommit());
```
## Route Notification Channels to Dedicated Queues ## Route Notification Channels to Dedicated Queues

View file

@ -52,7 +52,7 @@ ## Use Retry with Backoff for External APIs
Only retry on specific errors: Only retry on specific errors:
```php ```php
$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { $response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
return $exception instanceof ConnectionException return $exception instanceof ConnectionException
|| ($exception instanceof RequestException && $exception->response->serverError()); || ($exception instanceof RequestException && $exception->response->serverError());
})->post('https://api.example.com/data'); })->post('https://api.example.com/data');

View file

@ -10,7 +10,7 @@ ## Use `afterCommit()` on Mailables Inside Transactions
## Use `assertQueued()` Not `assertSent()` for Queued Mailables ## Use `assertQueued()` Not `assertSent()` for Queued Mailables
`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. `Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.

View file

@ -106,25 +106,23 @@ ## `retryUntil()` Needs `$tries = 0`
```php ```php
public $tries = 0; public $tries = 0;
public function retryUntil(): DateTime public function retryUntil(): \DateTimeInterface
{ {
return now()->addHours(4); return now()->addHours(4);
} }
``` ```
## Use `WithoutOverlapping::untilProcessing()` ## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
Prevents concurrent execution while allowing new instances to queue. `ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
```php ```php
public function middleware(): array class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{ {
return [new WithoutOverlapping($this->product->id)->untilProcessing()]; // Lock releases when processing begins, not when it finishes
} }
``` ```
Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts.
## Use Horizon for Complex Queue Scenarios ## Use Horizon for Complex Queue Scenarios
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.

View file

@ -36,7 +36,8 @@ ## Use Resource Controllers
```php ```php
Route::resource('posts', PostController::class); Route::resource('posts', PostController::class);
Route::apiResource('api/posts', Api\PostController::class); // In routes/api.php — the /api prefix is applied automatically
Route::apiResource('posts', Api\PostController::class);
``` ```
## Keep Controllers Thin ## Keep Controllers Thin

View file

@ -32,7 +32,7 @@ ## Authorize Every Action
Incorrect: Incorrect:
```php ```php
public function update(Request $request, Post $post) public function update(UpdatePostRequest $request, Post $post)
{ {
$post->update($request->validated()); $post->update($request->validated());
} }
@ -90,7 +90,7 @@ ## Escape Output to Prevent XSS
## CSRF Protection ## CSRF Protection
Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
Incorrect: Incorrect:
```blade ```blade
@ -121,7 +121,7 @@ ## Rate Limit Auth and API Routes
## Validate File Uploads ## Validate File Uploads
Validate MIME type, extension, and size. Never trust client-provided filenames. Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
```php ```php
public function rules(): array public function rules(): array

View file

@ -2,7 +2,7 @@ # Testing Best Practices
## Use `LazilyRefreshDatabase` Over `RefreshDatabase` ## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites. `RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
## Use Model Assertions Over Raw Database Assertions ## Use Model Assertions Over Raw Database Assertions

View file

@ -0,0 +1,96 @@
---
name: mcp-development
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
license: MIT
metadata:
author: laravel
---
# MCP Development
## Documentation
Use `search-docs` for detailed Laravel MCP patterns and documentation.
## Basic Usage
Register MCP servers in `routes/ai.php`:
<!-- Register MCP Server -->
```php
use Laravel\Mcp\Facades\Mcp;
Mcp::web();
```
### Creating MCP Primitives
Create MCP tools, resources, prompts, and servers using artisan commands:
```bash
php artisan make:mcp-tool ToolName # Create a tool
php artisan make:mcp-resource ResourceName # Create a resource
php artisan make:mcp-prompt PromptName # Create a prompt
php artisan make:mcp-server ServerName # Create a server
```
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
### Tools
<!-- MCP Tool Example -->
```php
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Request;
use Laravel\Mcp\Server\Response;
class MyTool extends Tool
{
public function handle(Request $request): Response
{
return new Response(['result' => 'success']);
}
}
```
### Registering Primitives in a Server
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
<!-- Register Primitives in MCP Server -->
```php
use Laravel\Mcp\Server;
class AppServer extends Server
{
protected array $tools = [
\App\Mcp\Tools\MyTool::class,
];
protected array $resources = [
\App\Mcp\Resources\MyResource::class,
];
protected array $prompts = [
\App\Mcp\Prompts\MyPrompt::class,
];
}
```
## Verification
1. Check `routes/ai.php` for proper registration
2. Test tool via MCP client
## Common Pitfalls
- Running `mcp:start` command (it hangs waiting for input)
- Using HTTPS locally with Node-based MCP clients
- Not using `search-docs` for the latest MCP documentation
- Not registering MCP server routes in `routes/ai.php`
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config

View file

@ -1,6 +1,6 @@
--- ---
name: pest-testing name: pest-testing
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT license: MIT
metadata: metadata:
author: laravel author: laravel
@ -18,6 +18,12 @@ ### Creating Tests
All tests must be written using Pest. Use `php artisan make:test --pest {name}`. All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
The `{name}` argument should include only the path and test name, but should not include the test suite.
- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php`
- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php`
- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php`
- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php`
### Test Organization ### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. - Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
@ -26,6 +32,8 @@ ### Test Organization
### Basic Test Structure ### Basic Test Structure
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
<!-- Basic Pest Test Example --> <!-- Basic Pest Test Example -->
```php ```php
it('is true', function () { it('is true', function () {
@ -155,3 +163,4 @@ ## Common Pitfalls
- Forgetting datasets for repetitive validation tests - Forgetting datasets for repetitive validation tests
- Deleting tests without approval - Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests - Forgetting `assertNoJavaScriptErrors()` in browser tests
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`

View file

@ -0,0 +1,404 @@
---
name: configure-nightwatch
description: Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads.
license: MIT
metadata:
author: laravel
---
# Nightwatch Configuration Guide
This skill helps configure Laravel Nightwatch data collection to balance observability, performance, and privacy. Covers sampling strategies, filtering rules, and redaction methods across all event types.
## Documentation Reference
The [Nightwatch Documentation](https://nightwatch.laravel.com/docs) is the definitive and up-to-date source of information for all Nightwatch configuration options. This skill provides practical guidance and common patterns, but always consult the official documentation as the primary source of truth for specific details, environment variables, and API behavior. The documentation includes comprehensive coverage of:
- [Filtering and Configuration](https://nightwatch.laravel.com/docs/filtering) - Core concepts for sampling, filtering, and redaction
- Individual event type pages with specific configuration options:
- [Requests](https://nightwatch.laravel.com/docs/requests) - Request sampling, header handling, payload capture
- [Commands](https://nightwatch.laravel.com/docs/commands) - Command sampling and redaction
- [Queries](https://nightwatch.laravel.com/docs/queries) - Query filtering and redaction
- [Cache](https://nightwatch.laravel.com/docs/cache) - Cache event filtering by key or pattern
- [Jobs](https://nightwatch.laravel.com/docs/jobs) - Job filtering and sampling decoupling
- [Mail](https://nightwatch.laravel.com/docs/mail) - Mail event filtering
- [Notifications](https://nightwatch.laravel.com/docs/notifications) - Notification filtering by channel
- [Exceptions](https://nightwatch.laravel.com/docs/exceptions) - Exception sampling and throttling
- [Outgoing Requests](https://nightwatch.laravel.com/docs/outgoing-requests) - HTTP request filtering
- [reference.md](reference.md) - Quick lookup table by event type, production presets, and verification checklist
## Data Collection Flow
Nightwatch processes events through three stages:
1. **Sampling** - Controls which entry points are captured (requests, commands, scheduled tasks)
2. **Filtering** - Excludes specific events after sampling (queries, cache, mail, etc.)
3. **Redaction** - Modifies captured data to remove/obfuscate sensitive information
```
Request/Command/Scheduled Task
|
v
[Sampling?] ----NO----> Drop entire trace
| YES
v
Events generated
|
v
[Filtering?] ----YES---> Drop specific event
| NO
v
[Redaction] ----------> Store modified data
```
---
## Sampling Configuration
Sampling determines which entry points (requests, commands, scheduled tasks) trigger full trace collection. When an entry point is sampled, all related events are captured.
### Global Sample Rates
Configure via environment variables:
```bash
# Default: 100% sampling (all requests/commands captured)
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 # Recommended: 10% of requests
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 # Capture all commands
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
```
**Recommendation**: Start with `0.1` (10%) for requests in production, adjust based on volume and needs.
### Route-Based Sampling
Apply different rates to specific routes using the `Sample` middleware:
```php routes/web.php
use Illuminate\Support\Facades\Route;
use Laravel\Nightwatch\Http\Middleware\Sample;
// Sample admin routes at 100%
Route::middleware(Sample::rate(1.0))->prefix('admin')->group(function () {
// All admin routes sampled fully
});
// Sample API routes at 5%
Route::middleware(Sample::rate(0.05))->prefix('api')->group(function () {
// API routes sampled sparingly
});
// Always sample critical endpoints
Route::post('/checkout', [CheckoutController::class, 'process'])
->middleware(Sample::always());
// Never sample health checks
Route::get('/health', [HealthController::class, 'check'])
->middleware(Sample::never());
```
### Unmatched Route Sampling
Handle 404/bot traffic with reduced sampling:
```php routes/web.php
Route::fallback(fn () => abort(404))
->middleware(Sample::rate(0.01)); // 1% sampling for unmatched routes
```
### Dynamic Sampling
Sample based on runtime conditions (user role, request attributes):
```php app/Http/Middleware/SampleAdminRequests.php
use Closure;
use Illuminate\Http\Request;
use Laravel\Nightwatch\Facades\Nightwatch;
class SampleAdminRequests
{
public function handle(Request $request, Closure $next)
{
if ($request->user()?->isAdmin()) {
Nightwatch::sample(); // Always sample admin requests
}
return $next($request);
}
}
```
### Command Sampling
Exclude specific commands from sampling:
```php AppServiceProvider.php
use Illuminate\Console\Events\CommandStarting;
use Illuminate\Support\Facades\Event;
use Laravel\Nightwatch\Facades\Nightwatch;
public function boot(): void
{
Event::listen(function (CommandStarting $event) {
if (in_array($event->command, ['schedule:finish', 'horizon:snapshot'])) {
Nightwatch::dontSample();
}
});
}
```
### Vendor Commands
Nightwatch automatically ignores framework/internal commands. Opt-in to capture them:
```php
Nightwatch::captureDefaultVendorCommands();
```
---
## Filtering Configuration
Filtering excludes specific events from collection after sampling. Use filtering to reduce noise and quota usage.
### Database Queries
**Filter all queries** (disable query collection):
```bash
NIGHTWATCH_IGNORE_QUERIES=true
```
**Filter specific queries** by SQL pattern:
```php AppServiceProvider.php
use Laravel\Nightwatch\Facades\Nightwatch;
use Laravel\Nightwatch\Records\Query;
public function boot(): void
{
// Filter job table queries (PostgreSQL)
Nightwatch::rejectQueries(function (Query $query) {
return str_contains($query->sql, 'into "jobs"');
});
// Filter cache table queries (MySQL)
Nightwatch::rejectQueries(function (Query $query) {
return str_contains($query->sql, 'from `cache`')
|| str_contains($query->sql, 'into `cache`');
});
}
```
### Cache Events
**Filter all cache events**:
```bash
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
```
**Filter by cache key patterns**:
```php
Nightwatch::rejectCacheKeys([
'my-app:users', // Exact match
'/^my-app:posts:/', // Regex: starts with my-app:posts:
'/^[a-zA-Z0-9]{40}$/', // Regex: session IDs
]);
```
**Filter with callback**:
```php
use Laravel\Nightwatch\Records\CacheEvent;
Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) {
return str_starts_with($cacheEvent->key, 'temp:');
});
```
### Mail Events
**Filter all mail**:
```bash
NIGHTWATCH_IGNORE_MAIL=true
```
**Filter specific mail**:
```php
use Laravel\Nightwatch\Records\Mail;
Nightwatch::rejectMail(function (Mail $mail) {
return str_contains($mail->subject, 'Newsletter');
});
```
### Notification Events
**Filter all notifications**:
```bash
NIGHTWATCH_IGNORE_NOTIFICATIONS=true
```
**Filter by channel**:
```php
use Laravel\Nightwatch\Records\Notification;
Nightwatch::rejectNotifications(function (Notification $notification) {
return $notification->channel === 'database';
});
```
### Outgoing HTTP Requests
**Filter all outgoing requests**:
```bash
NIGHTWATCH_IGNORE_OUTGOING_REQUESTS=true
```
**Filter by URL**:
```php
use Laravel\Nightwatch\Records\OutgoingRequest;
Nightwatch::rejectOutgoingRequests(function (OutgoingRequest $request) {
return str_contains($request->url, 'analytics.example.com');
});
```
### Queued Jobs
**Filter specific jobs**:
```php
use Laravel\Nightwatch\Records\QueuedJob;
Nightwatch::rejectQueuedJobs(function (QueuedJob $job) {
return $job->name === 'App\Jobs\LowPriorityJob';
});
```
### Decoupling Job Sampling
Sample jobs independently from parent contexts:
```php
use Illuminate\Support\Facades\Queue;
public function boot(): void
{
Queue::before(fn () => Nightwatch::sample(rate: 0.5));
}
```
---
## Redaction Configuration
Redaction modifies captured data to remove or obfuscate sensitive information. Unlike filtering, redaction keeps the event but sanitizes its content.
### Request Redaction
**Redact sensitive headers** (automatically redacts: Authorization, Cookie, X-XSRF-TOKEN):
```bash
# Customize redacted headers
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-API-Key
```
**Redact request payloads** (disabled by default):
```bash
# Enable payload capture
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=true
# Customize redacted fields
NIGHTWATCH_REDACT_PAYLOAD_FIELDS=password,password_confirmation,ssn,credit_card
```
**Programmatic redaction**:
```php
use Laravel\Nightwatch\Facades\Nightwatch;
use Laravel\Nightwatch\Records\Request;
Nightwatch::redactRequests(function (Request $request) {
$request->url = str_replace('secret', '***', $request->url);
$request->ip = preg_replace('/\d+$/', '***', $request->ip);
});
```
### Query Redaction
```php
use Laravel\Nightwatch\Records\Query;
Nightwatch::redactQueries(function (Query $query) {
$query->sql = str_replace('secret_token', '***', $query->sql);
});
```
### Cache Redaction
```php
use Laravel\Nightwatch\Records\CacheEvent;
Nightwatch::redactCacheEvents(function (CacheEvent $cacheEvent) {
$cacheEvent->key = str_replace('user:', 'user:***:', $cacheEvent->key);
});
```
### Command Redaction
```php
use Laravel\Nightwatch\Records\Command;
Nightwatch::redactCommands(function (Command $command) {
$command->command = preg_replace('/--password=\S+/', '--password=***', $command->command);
});
```
### Exception Redaction
```php
use Laravel\Nightwatch\Records\Exception;
Nightwatch::redactExceptions(function (Exception $exception) {
$exception->message = str_replace('secret', '***', $exception->message);
});
```
### Mail Redaction
```php
use Laravel\Nightwatch\Records\Mail;
Nightwatch::redactMail(function (Mail $mail) {
$mail->subject = str_replace('Invoice #', 'Invoice ***', $mail->subject);
});
```
### Outgoing Request Redaction
```php
use Laravel\Nightwatch\Records\OutgoingRequest;
Nightwatch::redactOutgoingRequests(function (OutgoingRequest $outgoingRequest) {
$outgoingRequest->url = preg_replace('/api_key=\w+/', 'api_key=***', $outgoingRequest->url);
});
```

View file

@ -0,0 +1,108 @@
# Nightwatch Configuration Reference
## Configuration Summary by Event Type
| Event Type | Sampling | Filtering | Redaction |
| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------- |
| **Requests** | `NIGHTWATCH_REQUEST_SAMPLE_RATE`, Route middleware | Not applicable | Headers, payload, URL, IP |
| **Commands** | `NIGHTWATCH_COMMAND_SAMPLE_RATE`, Event listener | Not applicable | Command arguments |
| **Queries** | Parent context | `rejectQueries()`, `NIGHTWATCH_IGNORE_QUERIES` | SQL statement |
| **Cache** | Parent context | `rejectCacheKeys()`, `rejectCacheEvents()`, `NIGHTWATCH_IGNORE_CACHE_EVENTS` | Cache key |
| **Jobs** | Parent context, Queue::before | `rejectQueuedJobs()` | Not applicable |
| **Mail** | Parent context | `rejectMail()`, `NIGHTWATCH_IGNORE_MAIL` | Subject |
| **Notifications** | Parent context | `rejectNotifications()`, `NIGHTWATCH_IGNORE_NOTIFICATIONS` | Not applicable |
| **Outgoing Requests** | Parent context | `rejectOutgoingRequests()`, `NIGHTWATCH_IGNORE_OUTGOING_REQUESTS` | URL |
| **Exceptions** | `NIGHTWATCH_EXCEPTION_SAMPLE_RATE` | Not applicable | Exception message |
---
## Production Recommendations
### High-Traffic Applications
```bash
# Conservative sampling
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.01 # 1% of requests
NIGHTWATCH_COMMAND_SAMPLE_RATE=0.1 # 10% of commands
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
# Filter noisy events
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
NIGHTWATCH_IGNORE_QUERIES=true # Or filter specific queries programmatically
```
### Privacy-Conscious Applications
```bash
# Disable sensitive data collection
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=false
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN
# Or use redaction in AppServiceProvider
```
### Balanced Configuration (Recommended Start)
```bash
# Sample rates
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0
# Filter obvious noise programmatically
# Redact PII as needed
```
---
## Verification Checklist
After configuration:
- [ ] Sampling rates appropriate for traffic volume
- [ ] Noisy events filtered (cache, certain queries)
- [ ] Sensitive data redacted (PII, tokens, credentials)
- [ ] Exceptions always captured for debugging
- [ ] Test in development with `NIGHTWATCH_REQUEST_SAMPLE_RATE=1.0`
- [ ] Monitor event quota usage in Nightwatch dashboard
---
## Common Patterns
### Filter Health Checks + Reduce Sampling
```php
Route::get('/health', fn() => ['status' => 'ok'])
->middleware(Sample::never());
```
### Exclude Internal/Vendor Queries
```php
Nightwatch::rejectQueries(fn($q) =>
str_contains($q->sql, 'telescope') ||
str_contains($q->sql, 'pulse')
);
```
### Protect User Data in Cache Keys
```php
Nightwatch::redactCacheEvents(fn($e) =>
$e->key = preg_replace('/user:\d+/', 'user:***', $e->key)
);
```

View file

@ -1,6 +1,6 @@
--- ---
name: fortify-development name: fortify-development
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.' description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
license: MIT license: MIT
metadata: metadata:
author: laravel author: laravel
@ -32,6 +32,7 @@ ## Available Features
- `Features::updateProfileInformation()` - Profile updates - `Features::updateProfileInformation()` - Profile updates
- `Features::updatePasswords()` - Password changes - `Features::updatePasswords()` - Password changes
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes - `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys
> Use `search-docs` for feature configuration options and customization patterns. > Use `search-docs` for feature configuration options and customization patterns.
@ -50,6 +51,18 @@ ### Two-Factor Authentication Setup
> Use `search-docs` for TOTP implementation and recovery code handling patterns. > Use `search-docs` for TOTP implementation and recovery code handling patterns.
### Passkeys Setup
```
- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser
- [ ] Enable passkeys feature in config/fortify.php
- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable
- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion
```
> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm.
### Email Verification Setup ### Email Verification Setup
``` ```
@ -129,3 +142,10 @@ ## Key Endpoints
| 2FA Challenge | POST | `/two-factor-challenge` | | 2FA Challenge | POST | `/two-factor-challenge` |
| Get QR Code | GET | `/user/two-factor-qr-code` | | Get QR Code | GET | `/user/two-factor-qr-code` |
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | | Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
| Passkey Login Options | GET | `/passkeys/login/options` |
| Passkey Login | POST | `/passkeys/login` |
| Passkey Confirm Options| GET | `/passkeys/confirm/options` |
| Passkey Confirm | POST | `/passkeys/confirm` |
| Passkey Options | GET | `/user/passkeys/options` |
| Register Passkey | POST | `/user/passkeys` |
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |

View file

@ -94,7 +94,7 @@ ### 8. Testing Patterns → `rules/testing.md`
### 9. Queue & Job Patterns → `rules/queue-jobs.md` ### 9. Queue & Job Patterns → `rules/queue-jobs.md`
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` - `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency - `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` - Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs - `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
- Horizon for complex multi-queue scenarios - Horizon for complex multi-queue scenarios

View file

@ -82,7 +82,7 @@ ## Code to Interfaces
## Default Sort by Descending ## Default Sort by Descending
When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres. When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
Incorrect: Incorrect:
```php ```php

View file

@ -2,7 +2,7 @@ # Caching Best Practices
## Use `Cache::remember()` Instead of Manual Get/Put ## Use `Cache::remember()` Instead of Manual Get/Put
Atomic pattern prevents race conditions and removes boilerplate. Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
Incorrect: Incorrect:
```php ```php

View file

@ -2,7 +2,7 @@ # Configuration Best Practices
## `env()` Only in Config Files ## `env()` Only in Config Files
Direct `env()` calls return `null` when config is cached. Direct `env()` calls may return `null` when config is cached.
Incorrect: Incorrect:
```php ```php

View file

@ -29,7 +29,11 @@ ## Always Queue Notifications
## Use `afterCommit()` on Notifications in Transactions ## Use `afterCommit()` on Notifications in Transactions
Same race condition as events — the queued notification job may run before the transaction commits. Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
```php
$user->notify((new InvoicePaid($invoice))->afterCommit());
```
## Route Notification Channels to Dedicated Queues ## Route Notification Channels to Dedicated Queues

View file

@ -52,7 +52,7 @@ ## Use Retry with Backoff for External APIs
Only retry on specific errors: Only retry on specific errors:
```php ```php
$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { $response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
return $exception instanceof ConnectionException return $exception instanceof ConnectionException
|| ($exception instanceof RequestException && $exception->response->serverError()); || ($exception instanceof RequestException && $exception->response->serverError());
})->post('https://api.example.com/data'); })->post('https://api.example.com/data');

View file

@ -10,7 +10,7 @@ ## Use `afterCommit()` on Mailables Inside Transactions
## Use `assertQueued()` Not `assertSent()` for Queued Mailables ## Use `assertQueued()` Not `assertSent()` for Queued Mailables
`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. `Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.

View file

@ -106,25 +106,23 @@ ## `retryUntil()` Needs `$tries = 0`
```php ```php
public $tries = 0; public $tries = 0;
public function retryUntil(): DateTime public function retryUntil(): \DateTimeInterface
{ {
return now()->addHours(4); return now()->addHours(4);
} }
``` ```
## Use `WithoutOverlapping::untilProcessing()` ## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
Prevents concurrent execution while allowing new instances to queue. `ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
```php ```php
public function middleware(): array class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{ {
return [new WithoutOverlapping($this->product->id)->untilProcessing()]; // Lock releases when processing begins, not when it finishes
} }
``` ```
Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts.
## Use Horizon for Complex Queue Scenarios ## Use Horizon for Complex Queue Scenarios
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.

View file

@ -36,7 +36,8 @@ ## Use Resource Controllers
```php ```php
Route::resource('posts', PostController::class); Route::resource('posts', PostController::class);
Route::apiResource('api/posts', Api\PostController::class); // In routes/api.php — the /api prefix is applied automatically
Route::apiResource('posts', Api\PostController::class);
``` ```
## Keep Controllers Thin ## Keep Controllers Thin

View file

@ -32,7 +32,7 @@ ## Authorize Every Action
Incorrect: Incorrect:
```php ```php
public function update(Request $request, Post $post) public function update(UpdatePostRequest $request, Post $post)
{ {
$post->update($request->validated()); $post->update($request->validated());
} }
@ -90,7 +90,7 @@ ## Escape Output to Prevent XSS
## CSRF Protection ## CSRF Protection
Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
Incorrect: Incorrect:
```blade ```blade
@ -121,7 +121,7 @@ ## Rate Limit Auth and API Routes
## Validate File Uploads ## Validate File Uploads
Validate MIME type, extension, and size. Never trust client-provided filenames. Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
```php ```php
public function rules(): array public function rules(): array

View file

@ -2,7 +2,7 @@ # Testing Best Practices
## Use `LazilyRefreshDatabase` Over `RefreshDatabase` ## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites. `RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
## Use Model Assertions Over Raw Database Assertions ## Use Model Assertions Over Raw Database Assertions

View file

@ -0,0 +1,96 @@
---
name: mcp-development
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
license: MIT
metadata:
author: laravel
---
# MCP Development
## Documentation
Use `search-docs` for detailed Laravel MCP patterns and documentation.
## Basic Usage
Register MCP servers in `routes/ai.php`:
<!-- Register MCP Server -->
```php
use Laravel\Mcp\Facades\Mcp;
Mcp::web();
```
### Creating MCP Primitives
Create MCP tools, resources, prompts, and servers using artisan commands:
```bash
php artisan make:mcp-tool ToolName # Create a tool
php artisan make:mcp-resource ResourceName # Create a resource
php artisan make:mcp-prompt PromptName # Create a prompt
php artisan make:mcp-server ServerName # Create a server
```
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
### Tools
<!-- MCP Tool Example -->
```php
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Request;
use Laravel\Mcp\Server\Response;
class MyTool extends Tool
{
public function handle(Request $request): Response
{
return new Response(['result' => 'success']);
}
}
```
### Registering Primitives in a Server
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
<!-- Register Primitives in MCP Server -->
```php
use Laravel\Mcp\Server;
class AppServer extends Server
{
protected array $tools = [
\App\Mcp\Tools\MyTool::class,
];
protected array $resources = [
\App\Mcp\Resources\MyResource::class,
];
protected array $prompts = [
\App\Mcp\Prompts\MyPrompt::class,
];
}
```
## Verification
1. Check `routes/ai.php` for proper registration
2. Test tool via MCP client
## Common Pitfalls
- Running `mcp:start` command (it hangs waiting for input)
- Using HTTPS locally with Node-based MCP clients
- Not using `search-docs` for the latest MCP documentation
- Not registering MCP server routes in `routes/ai.php`
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config

View file

@ -1,6 +1,6 @@
--- ---
name: pest-testing name: pest-testing
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT license: MIT
metadata: metadata:
author: laravel author: laravel
@ -18,6 +18,12 @@ ### Creating Tests
All tests must be written using Pest. Use `php artisan make:test --pest {name}`. All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
The `{name}` argument should include only the path and test name, but should not include the test suite.
- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php`
- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php`
- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php`
- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php`
### Test Organization ### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. - Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
@ -26,6 +32,8 @@ ### Test Organization
### Basic Test Structure ### Basic Test Structure
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
<!-- Basic Pest Test Example --> <!-- Basic Pest Test Example -->
```php ```php
it('is true', function () { it('is true', function () {
@ -155,3 +163,4 @@ ## Common Pitfalls
- Forgetting datasets for repetitive validation tests - Forgetting datasets for repetitive validation tests
- Deleting tests without approval - Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests - Forgetting `assertNoJavaScriptErrors()` in browser tests
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`

View file

@ -17,6 +17,7 @@ ## Foundational Context
- laravel/fortify (FORTIFY) - v1 - laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5 - laravel/horizon (HORIZON) - v5
- laravel/mcp (MCP) - v0
- laravel/nightwatch (NIGHTWATCH) - v1 - laravel/nightwatch (NIGHTWATCH) - v1
- laravel/pail (PAIL) - v1 - laravel/pail (PAIL) - v1
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
@ -25,29 +26,16 @@ ## Foundational Context
- livewire/livewire (LIVEWIRE) - v3 - livewire/livewire (LIVEWIRE) - v3
- laravel/boost (BOOST) - v2 - laravel/boost (BOOST) - v2
- laravel/dusk (DUSK) - v8 - laravel/dusk (DUSK) - v8
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
- laravel/telescope (TELESCOPE) - v5 - laravel/telescope (TELESCOPE) - v5
- pestphp/pest (PEST) - v4 - pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12 - phpunit/phpunit (PHPUNIT) - v12
- rector/rector (RECTOR) - v2 - rector/rector (RECTOR) - v2
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4 - tailwindcss (TAILWINDCSS) - v4
- vue (VUE) - v3
## Skills Activation ## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire.
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
- `laravel-actions` — Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring.
- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application.
## Conventions ## Conventions
@ -107,7 +95,6 @@ ## Artisan
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. - Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. - Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. - Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
- To check environment variables, read the `.env` file directly.
## Tinker ## Tinker
@ -122,10 +109,16 @@ # PHP
- Always use curly braces for control structures, even for single-line bodies. - Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. - Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` - Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. - Follow existing application Enum naming conventions.
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. - Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks. - Use array shape type definitions in PHPDoc blocks.
=== deployments rules ===
# Deployment
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
=== tests rules === === tests rules ===
# Test Enforcement # Test Enforcement
@ -209,6 +202,7 @@ # Laravel Pint Code Formatter
## Pest ## Pest
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. - This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`.
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. - Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
- Do NOT delete tests without approval. - Do NOT delete tests without approval.

View file

@ -120,6 +120,7 @@ ## Foundational Context
- laravel/fortify (FORTIFY) - v1 - laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5 - laravel/horizon (HORIZON) - v5
- laravel/mcp (MCP) - v0
- laravel/nightwatch (NIGHTWATCH) - v1 - laravel/nightwatch (NIGHTWATCH) - v1
- laravel/pail (PAIL) - v1 - laravel/pail (PAIL) - v1
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
@ -128,29 +129,16 @@ ## Foundational Context
- livewire/livewire (LIVEWIRE) - v3 - livewire/livewire (LIVEWIRE) - v3
- laravel/boost (BOOST) - v2 - laravel/boost (BOOST) - v2
- laravel/dusk (DUSK) - v8 - laravel/dusk (DUSK) - v8
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
- laravel/telescope (TELESCOPE) - v5 - laravel/telescope (TELESCOPE) - v5
- pestphp/pest (PEST) - v4 - pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12 - phpunit/phpunit (PHPUNIT) - v12
- rector/rector (RECTOR) - v2 - rector/rector (RECTOR) - v2
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4 - tailwindcss (TAILWINDCSS) - v4
- vue (VUE) - v3
## Skills Activation ## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire.
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
- `laravel-actions` — Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring.
- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application.
## Conventions ## Conventions
@ -210,7 +198,6 @@ ## Artisan
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. - Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. - Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. - Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
- To check environment variables, read the `.env` file directly.
## Tinker ## Tinker
@ -225,10 +212,16 @@ # PHP
- Always use curly braces for control structures, even for single-line bodies. - Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. - Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` - Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. - Follow existing application Enum naming conventions.
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. - Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks. - Use array shape type definitions in PHPDoc blocks.
=== deployments rules ===
# Deployment
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
=== tests rules === === tests rules ===
# Test Enforcement # Test Enforcement
@ -312,6 +305,7 @@ # Laravel Pint Code Formatter
## Pest ## Pest
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. - This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`.
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. - Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
- Do NOT delete tests without approval. - Do NOT delete tests without approval.

View file

@ -5,6 +5,7 @@
use App\Models\Subscription; use App\Models\Subscription;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient; use Stripe\StripeClient;
class CancelSubscription class CancelSubscription
@ -21,7 +22,7 @@ public function __construct(User $user, bool $isDryRun = false)
$this->isDryRun = $isDryRun; $this->isDryRun = $isDryRun;
if (! $isDryRun && isCloud()) { if (! $isDryRun && isCloud()) {
$this->stripe = new StripeClient(config('subscription.stripe_api_key')); $this->stripe = app(StripeClient::class);
} }
} }
@ -64,7 +65,7 @@ public function verifySubscriptionsInStripe(): array
]; ];
} }
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$subscriptions = $this->getSubscriptionsPreview(); $subscriptions = $this->getSubscriptionsPreview();
$verified = collect(); $verified = collect();
@ -88,7 +89,7 @@ public function verifySubscriptionsInStripe(): array
'reason' => "Status in Stripe: {$stripeSubscription->status}", 'reason' => "Status in Stripe: {$stripeSubscription->status}",
]); ]);
} }
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
// Subscription doesn't exist in Stripe // Subscription doesn't exist in Stripe
$notFound->push([ $notFound->push([
'subscription' => $subscription, 'subscription' => $subscription,
@ -181,7 +182,7 @@ public static function cancelById(string $subscriptionId): bool
return false; return false;
} }
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$stripe->subscriptions->cancel($subscriptionId, []); $stripe->subscriptions->cancel($subscriptionId, []);
// Update local record if exists // Update local record if exists

View file

@ -3,6 +3,7 @@
namespace App\Actions\Stripe; namespace App\Actions\Stripe;
use App\Models\Team; use App\Models\Team;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient; use Stripe\StripeClient;
class CancelSubscriptionAtPeriodEnd class CancelSubscriptionAtPeriodEnd
@ -11,7 +12,7 @@ class CancelSubscriptionAtPeriodEnd
public function __construct(?StripeClient $stripe = null) public function __construct(?StripeClient $stripe = null)
{ {
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); $this->stripe = $stripe ?? app(StripeClient::class);
} }
/** /**
@ -47,7 +48,7 @@ public function execute(Team $team): array
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}"); \Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
return ['success' => true, 'error' => null]; return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage()); \Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];

View file

@ -3,6 +3,8 @@
namespace App\Actions\Stripe; namespace App\Actions\Stripe;
use App\Models\Team; use App\Models\Team;
use Carbon\Carbon;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient; use Stripe\StripeClient;
class RefundSubscription class RefundSubscription
@ -13,7 +15,7 @@ class RefundSubscription
public function __construct(?StripeClient $stripe = null) public function __construct(?StripeClient $stripe = null)
{ {
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); $this->stripe = $stripe ?? app(StripeClient::class);
} }
/** /**
@ -39,7 +41,7 @@ public function checkEligibility(Team $team): array
try { try {
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id); $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
return $this->ineligible('Subscription not found in Stripe.'); return $this->ineligible('Subscription not found in Stripe.');
} }
@ -49,7 +51,7 @@ public function checkEligibility(Team $team): array
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd); return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
} }
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date); $startDate = Carbon::createFromTimestamp($stripeSubscription->start_date);
$daysSinceStart = (int) $startDate->diffInDays(now()); $daysSinceStart = (int) $startDate->diffInDays(now());
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart; $daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
@ -130,7 +132,7 @@ public function execute(Team $team): array
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}"); \Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
return ['success' => true, 'error' => null]; return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage()); \Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];

View file

@ -3,6 +3,7 @@
namespace App\Actions\Stripe; namespace App\Actions\Stripe;
use App\Models\Team; use App\Models\Team;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient; use Stripe\StripeClient;
class ResumeSubscription class ResumeSubscription
@ -11,7 +12,7 @@ class ResumeSubscription
public function __construct(?StripeClient $stripe = null) public function __construct(?StripeClient $stripe = null)
{ {
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); $this->stripe = $stripe ?? app(StripeClient::class);
} }
/** /**
@ -43,7 +44,7 @@ public function execute(Team $team): array
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}"); \Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
return ['success' => true, 'error' => null]; return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage()); \Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];

View file

@ -17,7 +17,7 @@ class UpdateSubscriptionQuantity
public function __construct(?StripeClient $stripe = null) public function __construct(?StripeClient $stripe = null)
{ {
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); $this->stripe = $stripe ?? app(StripeClient::class);
} }
/** /**

View file

@ -4,6 +4,8 @@
use App\Models\Team; use App\Models\Team;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class CloudFixSubscription extends Command class CloudFixSubscription extends Command
{ {
@ -31,7 +33,7 @@ class CloudFixSubscription extends Command
*/ */
public function handle() public function handle()
{ {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
if ($this->option('verify-all')) { if ($this->option('verify-all')) {
return $this->verifyAllActiveSubscriptions($stripe); return $this->verifyAllActiveSubscriptions($stripe);
@ -111,7 +113,7 @@ public function handle()
/** /**
* Fix canceled subscriptions in the database * Fix canceled subscriptions in the database
*/ */
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe) private function fixCanceledSubscriptions(StripeClient $stripe)
{ {
$isDryRun = $this->option('dry-run'); $isDryRun = $this->option('dry-run');
$checkOne = $this->option('one'); $checkOne = $this->option('one');
@ -220,7 +222,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
break; break;
} }
} }
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
if ($e->getStripeCode() === 'resource_missing') { if ($e->getStripeCode() === 'resource_missing') {
$toFixCount++; $toFixCount++;
@ -326,7 +328,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
/** /**
* Verify all active subscriptions against Stripe API * Verify all active subscriptions against Stripe API
*/ */
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe) private function verifyAllActiveSubscriptions(StripeClient $stripe)
{ {
$isDryRun = $this->option('dry-run'); $isDryRun = $this->option('dry-run');
$shouldFix = $this->option('fix-verified'); $shouldFix = $this->option('fix-verified');
@ -570,7 +572,7 @@ private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
break; break;
} }
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
$this->error(' → Error: '.$e->getMessage()); $this->error(' → Error: '.$e->getMessage());
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) { if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
@ -730,7 +732,7 @@ private function fixSubscription($team, $subscription, $status)
/** /**
* Search for subscriptions by customer ID * Search for subscriptions by customer ID
*/ */
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false) private function searchSubscriptionsByCustomer(StripeClient $stripe, $customerId, $requireActive = false)
{ {
try { try {
$subscriptions = $stripe->subscriptions->all([ $subscriptions = $stripe->subscriptions->all([
@ -770,7 +772,7 @@ private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $cu
/** /**
* Search for subscriptions by team member emails * Search for subscriptions by team member emails
*/ */
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails) private function searchSubscriptionsByEmails(StripeClient $stripe, $emails)
{ {
$this->line(' → Searching by team member emails...'); $this->line(' → Searching by team member emails...');

View file

@ -14,6 +14,7 @@
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
@ -71,16 +72,25 @@ private function handleClosedAction(Application $application): void
->first(); ->first();
if ($found) { if ($found) {
ApplicationPullRequestUpdateJob::dispatchSync( try {
application: $application, $this->dispatchPullRequestClosedUpdate($application, $found);
preview: $found, } catch (Throwable $e) {
status: ProcessStatus::CLOSED report($e);
); } finally {
CleanupPreviewDeployment::run($application, $this->pullRequestId, $found);
CleanupPreviewDeployment::run($application, $this->pullRequestId, $found); }
} }
} }
protected function dispatchPullRequestClosedUpdate(Application $application, ApplicationPreview $preview): void
{
ApplicationPullRequestUpdateJob::dispatchSync(
application: $application,
preview: $preview,
status: ProcessStatus::CLOSED
);
}
private function handleOpenAction(Application $application, ?GithubApp $githubApp): void private function handleOpenAction(Application $application, ?GithubApp $githubApp): void
{ {
if (! $application->isPRDeployable()) { if (! $application->isPRDeployable()) {

View file

@ -36,7 +36,7 @@ public function handle(): void
$data = data_get($this->event, 'data.object'); $data = data_get($this->event, 'data.object');
switch ($type) { switch ($type) {
case 'radar.early_fraud_warning.created': case 'radar.early_fraud_warning.created':
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$id = data_get($data, 'id'); $id = data_get($data, 'id');
$charge = data_get($data, 'charge'); $charge = data_get($data, 'charge');
if ($charge) { if ($charge) {
@ -100,7 +100,7 @@ public function handle(): void
if ($subscription->stripe_subscription_id) { if ($subscription->stripe_subscription_id) {
try { try {
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$stripeSubscription = $stripe->subscriptions->retrieve( $stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id $subscription->stripe_subscription_id
); );
@ -166,7 +166,7 @@ public function handle(): void
// Verify payment status with Stripe API before sending failure notification // Verify payment status with Stripe API before sending failure notification
if ($paymentIntentId) { if ($paymentIntentId) {
try { try {
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId); $paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) { if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {

View file

@ -10,6 +10,7 @@
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
{ {
@ -27,7 +28,7 @@ public function handle()
$subscription = $this->team->subscription; $subscription = $this->team->subscription;
if ($subscription && $subscription->stripe_customer_id) { if ($subscription && $subscription->stripe_customer_id) {
try { try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
if ($subscription->stripe_subscription_id) { if ($subscription->stripe_subscription_id) {
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id); $stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);

View file

@ -9,6 +9,7 @@
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
{ {
@ -33,7 +34,7 @@ public function handle(?\Closure $onProgress = null): array
->where('stripe_invoice_paid', true) ->where('stripe_invoice_paid', true)
->get(); ->get();
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
// Bulk fetch all valid subscription IDs from Stripe (active + past_due) // Bulk fetch all valid subscription IDs from Stripe (active + past_due)
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress); $validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
@ -123,7 +124,7 @@ public function handle(?\Closure $onProgress = null): array
* *
* @return array{email: string, customer_id: string, subscription_id: string, status: string}|null * @return array{email: string, customer_id: string, subscription_id: string, status: string}|null
*/ */
private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array private function findActiveSubscriptionByEmail(StripeClient $stripe, string $customerId): ?array
{ {
try { try {
$customer = $stripe->customers->retrieve($customerId); $customer = $stripe->customers->retrieve($customerId);
@ -177,7 +178,7 @@ private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, str
* *
* @return array<string> * @return array<string>
*/ */
private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $onProgress = null): array
{ {
$validIds = []; $validIds = [];
$fetched = 0; $fetched = 0;

View file

@ -9,6 +9,7 @@
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
{ {
@ -29,7 +30,7 @@ public function handle(): void
if (! $this->subscription->stripe_subscription_id && if (! $this->subscription->stripe_subscription_id &&
$this->subscription->stripe_customer_id) { $this->subscription->stripe_customer_id) {
try { try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$subscriptions = $stripe->subscriptions->all([ $subscriptions = $stripe->subscriptions->all([
'customer' => $this->subscription->stripe_customer_id, 'customer' => $this->subscription->stripe_customer_id,
'limit' => 1, 'limit' => 1,
@ -50,7 +51,7 @@ public function handle(): void
} }
try { try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$stripeSubscription = $stripe->subscriptions->retrieve( $stripeSubscription = $stripe->subscriptions->retrieve(
$this->subscription->stripe_subscription_id $this->subscription->stripe_subscription_id
); );

View file

@ -32,6 +32,8 @@ class Actions extends Component
public bool $refundAlreadyUsed = false; public bool $refundAlreadyUsed = false;
public bool $refundLatestPayment = false;
public string $billingInterval = 'monthly'; public string $billingInterval = 'monthly';
public ?string $nextBillingDate = null; public ?string $nextBillingDate = null;
@ -100,7 +102,7 @@ public function refundSubscription(string $password): bool|string
return 'Invalid password.'; return 'Invalid password.';
} }
$result = (new RefundSubscription)->execute(currentTeam()); $result = app(RefundSubscription::class)->execute(currentTeam());
if ($result['success']) { if ($result['success']) {
$this->dispatch('success', 'Subscription refunded successfully.'); $this->dispatch('success', 'Subscription refunded successfully.');
@ -114,12 +116,28 @@ public function refundSubscription(string $password): bool|string
return true; return true;
} }
public function cancelImmediately(string $password): bool|string public function cancelImmediately(string $password, array $selectedActions = []): bool|string
{ {
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) { if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
return 'Invalid password.'; return 'Invalid password.';
} }
if (in_array('refundLatestPayment', $selectedActions, true)) {
// Eligibility is re-validated server-side inside RefundSubscription::execute()
$result = app(RefundSubscription::class)->execute(currentTeam());
if ($result['success']) {
$this->dispatch('success', 'Subscription refunded and cancelled successfully.');
$this->redirect(route('subscription.index'), navigate: true);
return true;
}
$this->dispatch('error', 'Something went wrong with the refund. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
return true;
}
$team = currentTeam(); $team = currentTeam();
$subscription = $team->subscription; $subscription = $team->subscription;
@ -130,7 +148,7 @@ public function cancelImmediately(string $password): bool|string
} }
try { try {
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$stripe->subscriptions->cancel($subscription->stripe_subscription_id); $stripe->subscriptions->cancel($subscription->stripe_subscription_id);
$subscription->update([ $subscription->update([

View file

@ -5,6 +5,7 @@
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Livewire\Component; use Livewire\Component;
use Stripe\StripeClient;
class Index extends Component class Index extends Component
{ {
@ -52,7 +53,7 @@ public function getStripeStatus()
{ {
try { try {
$subscription = currentTeam()->subscription; $subscription = currentTeam()->subscription;
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id); $customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id);
if ($customer) { if ($customer) {
$subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]); $subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]);

View file

@ -626,7 +626,7 @@ public function extraFields()
} }
$fields->put('Unleash', $data->toArray()); $fields->put('Unleash', $data->toArray());
break; break;
case $image->contains('grafana'): case $this->isGrafanaImage($image->toString()):
$data = collect([]); $data = collect([]);
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GRAFANA')->first(); $admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GRAFANA')->first();
$data = $data->merge([ $data = $data->merge([
@ -1380,6 +1380,15 @@ public function extraFields()
return $fields; return $fields;
} }
private function isGrafanaImage(string $image): bool
{
return in_array($image, [
'grafana/grafana',
'grafana/grafana-oss',
'grafana/grafana-enterprise',
], true);
}
public function saveExtraFields($fields) public function saveExtraFields($fields)
{ {
foreach ($fields as $field) { foreach ($fields as $field) {

View file

@ -11,6 +11,7 @@
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use Laravel\Telescope\TelescopeServiceProvider; use Laravel\Telescope\TelescopeServiceProvider;
use Stripe\StripeClient;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -19,6 +20,8 @@ public function register(): void
if (App::isLocal()) { if (App::isLocal()) {
$this->app->register(TelescopeServiceProvider::class); $this->app->register(TelescopeServiceProvider::class);
} }
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));
} }
public function boot(): void public function boot(): void

View file

@ -5,23 +5,25 @@
"codex", "codex",
"opencode" "opencode"
], ],
"cloud": false,
"guidelines": true, "guidelines": true,
"mcp": true, "mcp": true,
"nightwatch_mcp": false, "nightwatch_mcp": false,
"packages": [ "packages": [
"laravel/fortify",
"spatie/laravel-ray", "spatie/laravel-ray",
"lorisleiva/laravel-actions" "lorisleiva/laravel-actions"
], ],
"sail": false, "sail": false,
"skills": [ "skills": [
"fortify-development",
"laravel-best-practices", "laravel-best-practices",
"configuring-horizon", "configuring-horizon",
"mcp-development",
"configure-nightwatch",
"socialite-development", "socialite-development",
"livewire-development", "livewire-development",
"pest-testing", "pest-testing",
"tailwindcss-development", "tailwindcss-development",
"fortify-development",
"laravel-actions", "laravel-actions",
"debugging-output-and-previewing-html-using-ray" "debugging-output-and-previewing-html-using-ray"
] ]

View file

@ -2,7 +2,7 @@
return [ return [
'coolify' => [ 'coolify' => [
'version' => '4.1.2', 'version' => '4.2.0',
'helper_version' => '1.0.14', 'helper_version' => '1.0.14',
'realtime_version' => '1.0.16', 'realtime_version' => '1.0.16',
'railpack_version' => '0.23.0', 'railpack_version' => '0.23.0',

View file

@ -11,12 +11,21 @@
*/ */
public function up(): void public function up(): void
{ {
// SQLite (testing) uses type affinity, so json columns already accept text
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
} }
public function down(): void public function down(): void
{ {
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
} }

View file

@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.1.2" "version": "4.2.0"
}, },
"nightly": { "nightly": {
"version": "4.2.0" "version": "4.2.1"
}, },
"helper": { "helper": {
"version": "1.0.14" "version": "1.0.14"

View file

@ -48,11 +48,11 @@
characterData: true characterData: true
});" x-destroy="observer && observer.disconnect()" });" x-destroy="observer && observer.disconnect()"
@class([ @class([
'flex flex-col w-full px-4 py-2 overflow-y-auto bg-white border border-solid rounded-sm dark:text-white dark:bg-coolgray-100 scrollbar border-neutral-300 dark:border-coolgray-300', 'flex flex-col w-full min-w-0 max-w-full px-4 py-2 overflow-y-auto bg-white border border-solid rounded-sm dark:text-white dark:bg-coolgray-100 scrollbar border-neutral-300 dark:border-coolgray-300',
'flex-1 min-h-0' => $fullHeight, 'flex-1 min-h-0' => $fullHeight,
'max-h-96' => !$fullHeight, 'max-h-96' => !$fullHeight,
])> ])>
<pre class="font-logs whitespace-pre-wrap" @if ($isPollingActive) wire:poll.1000ms="polling" @endif>{{ RunRemoteProcess::decodeOutput($activity) }}</pre> <pre class="font-logs min-w-0 max-w-full whitespace-pre-wrap wrap-anywhere" @if ($isPollingActive) wire:poll.1000ms="polling" @endif>{{ RunRemoteProcess::decodeOutput($activity) }}</pre>
</div> </div>
@else @else
@if ($showWaiting) @if ($showWaiting)

View file

@ -166,7 +166,113 @@
<x-resources.breadcrumbs :resource="$application" :parameters="$parameters" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" /> <x-resources.breadcrumbs :resource="$application" :parameters="$parameters" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" />
<div class="navbar-main"> <div class="navbar-main">
<div class="w-full md:hidden"> <div class="w-full md:hidden">
<label for="application-mobile-section" class="sr-only">Application menu</label> @if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)))
<div id="application-mobile-actions" class="mt-2 mb-3 md:hidden">
<div class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Actions</div>
<div class="flex flex-nowrap items-center gap-2 overflow-x-auto">
@if (!str($application->status)->startsWith('exited'))
@if (!$application->destination->server->isSwarm())
<button type="button" class="button shrink-0"
@click="document.getElementById('application-mobile-deploy-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-orange-400"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M10.09 4.01l.496 -.495a2 2 0 0 1 2.828 0l7.071 7.07a2 2 0 0 1 0 2.83l-7.07 7.07a2 2 0 0 1 -2.83 0l-7.07 -7.07a2 2 0 0 1 0 -2.83l3.535 -3.535h-3.988">
</path>
<path d="M7.05 11.038v-3.988"></path>
</svg>
Redeploy
</button>
@endif
@if ($application->build_pack !== 'dockercompose')
@if ($application->destination->server->isSwarm())
<button type="button" class="button shrink-0"
@click="document.getElementById('application-mobile-deploy-trigger')?.click()">
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2">
<path
d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
<path d="M20 4v5h-5" />
</g>
</svg>
Update Service
</button>
@else
<button type="button" class="button shrink-0"
@click="document.getElementById('application-mobile-restart-trigger')?.click()">
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2">
<path
d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
<path d="M20 4v5h-5" />
</g>
</svg>
Restart
</button>
@endif
@endif
<x-forms.button isError class="shrink-0"
@click="document.getElementById('application-mobile-stop-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
<path
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
</svg>
Stop
</x-forms.button>
@else
<button type="button" class="button shrink-0"
@click="document.getElementById('application-mobile-deploy-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>
Deploy
</button>
@endif
@if (!$application->destination->server->isSwarm())
@if ($application->status === 'running')
<button type="button" class="button shrink-0"
@click="document.getElementById('application-mobile-force-deploy-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>
Force deploy (without cache)
</button>
@else
<button type="button" class="button shrink-0"
@click="document.getElementById('application-mobile-deploy-force-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>
Force deploy (without cache)
</button>
@endif
@endif
</div>
</div>
@endif
<label id="application-mobile-section-label" for="application-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
<select id="application-mobile-section" class="select w-full" aria-label="Application menu" <select id="application-mobile-section" class="select w-full" aria-label="Application menu"
data-current-value="{{ $activeMobileMenuValue }}" data-current-value="{{ $activeMobileMenuValue }}"
x-data="{ x-data="{
@ -282,32 +388,6 @@
<option disabled>No links available</option> <option disabled>No links available</option>
@endif @endif
</optgroup> </optgroup>
@if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)))
<optgroup label="Actions">
@if (!str($application->status)->startsWith('exited'))
@if (!$application->destination->server->isSwarm())
<option value="action:deploy">Redeploy</option>
@endif
@if ($application->build_pack !== 'dockercompose')
@if ($application->destination->server->isSwarm())
<option value="action:deploy">Update Service</option>
@else
<option value="action:restart">Restart</option>
@endif
@endif
<option value="action:stop">Stop</option>
@else
<option value="action:deploy">Deploy</option>
@endif
@if (!$application->destination->server->isSwarm())
@if ($application->status === 'running')
<option value="action:force-deploy">Force deploy (without cache)</option>
@else
<option value="action:deploy-force">Force deploy (without cache)</option>
@endif
@endif
</optgroup>
@endif
</select> </select>
<x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop" <x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop"
submitAction="stop" :checkboxes="$checkboxes" :actions="[ submitAction="stop" :checkboxes="$checkboxes" :actions="[

View file

@ -73,14 +73,60 @@
<x-slide-over @startdatabase.window="slideOverOpen = true" closeWithX fullScreen> <x-slide-over @startdatabase.window="slideOverOpen = true" closeWithX fullScreen>
<x-slot:title>Database Startup</x-slot:title> <x-slot:title>Database Startup</x-slot:title>
<x-slot:content> <x-slot:content>
<div wire:ignore> <div wire:ignore class="h-full min-h-0 min-w-0 max-w-full">
<livewire:activity-monitor header="Logs" fullHeight /> <livewire:activity-monitor header="Logs" fullHeight />
</div> </div>
</x-slot:content> </x-slot:content>
</x-slide-over> </x-slide-over>
<div class="navbar-main"> <div class="navbar-main">
<div class="w-full md:hidden"> <div class="w-full md:hidden">
<label for="database-mobile-section" class="sr-only">Database menu</label> @if ($database->destination->server->isFunctional())
<div id="database-mobile-actions" class="mt-2 mb-3 md:hidden">
<div class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Actions</div>
<div class="flex flex-nowrap items-center gap-2 overflow-x-auto">
@if (!str($database->status)->startsWith('exited'))
<button type="button" class="button shrink-0"
@click="document.getElementById('database-restart-trigger')?.click()">
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
<path d="M20 4v5h-5" />
</g>
</svg>
Restart
</button>
<x-forms.button isError class="shrink-0"
@click="document.getElementById('database-stop-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
<path
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
</svg>
Stop
</x-forms.button>
@else
<button type="button" class="button shrink-0"
@click="document.getElementById('database-start-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>
Start
</button>
@endif
</div>
</div>
@endif
<label id="database-mobile-section-label" for="database-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
<select id="database-mobile-section" class="select w-full" aria-label="Database menu" <select id="database-mobile-section" class="select w-full" aria-label="Database menu"
data-current-value="{{ $activeDatabaseMobileValue }}" data-current-value="{{ $activeDatabaseMobileValue }}"
x-data="{ x-data="{
@ -135,16 +181,6 @@
</option> </option>
@endforeach @endforeach
</optgroup> </optgroup>
@if ($database->destination->server->isFunctional())
<optgroup label="Actions">
@if (!str($database->status)->startsWith('exited'))
<option value="action:restart">Restart</option>
<option value="action:stop">Stop</option>
@else
<option value="action:start">Start</option>
@endif
</optgroup>
@endif
</select> </select>
</div> </div>
<nav <nav

View file

@ -100,7 +100,172 @@
<x-resources.breadcrumbs :resource="$service" :parameters="$parameters" /> <x-resources.breadcrumbs :resource="$service" :parameters="$parameters" />
<div class="navbar-main" x-data"> <div class="navbar-main" x-data">
<div class="mb-4 w-full md:mb-0 md:hidden"> <div class="mb-4 w-full md:mb-0 md:hidden">
<label for="service-mobile-section" class="sr-only">Service menu</label> @if ($service->isDeployable)
<div id="service-mobile-actions" class="mt-2 mb-3 md:hidden">
<div class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Actions</div>
<div class="flex flex-nowrap items-center gap-2 overflow-x-auto">
@if (str($service->status)->contains('running'))
<button type="button" class="button shrink-0"
@click="document.getElementById('service-restart-trigger')?.click()">
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path d="M19.933 13.041 a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
<path d="M20 4v5h-5" />
</g>
</svg>
Restart
</button>
<x-forms.button isError class="shrink-0"
@click="document.getElementById('service-stop-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
<path
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
</svg>
Stop
</x-forms.button>
<button type="button" class="button shrink-0"
@click="document.getElementById('service-pullAndRestart-trigger')?.click()">
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path d="M19.933 13.041 a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
<path d="M20 4v5h-5" />
</g>
</svg>
Pull Latest Images & Restart
</button>
@elseif (str($service->status)->contains('degraded'))
<button type="button" class="button shrink-0"
@click="document.getElementById('service-restart-trigger')?.click()">
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
<path d="M20 4v5h-5" />
</g>
</svg>
Restart
</button>
<x-forms.button isError class="shrink-0"
@click="document.getElementById('service-stop-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
<path
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
</svg>
Stop
</x-forms.button>
<button type="button" class="button shrink-0"
@click="document.getElementById('service-forceDeploy-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>
Force Restart
</button>
@elseif (str($service->status)->contains('exited'))
<button type="button" class="button shrink-0"
@click="document.getElementById('service-start-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>
Deploy
</button>
<button type="button" class="button shrink-0"
@click="document.getElementById('service-forceDeploy-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>
Force Deploy
</button>
<button type="button" class="button shrink-0"
@click="document.getElementById('service-cleanup-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
<path
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
</svg>
Force Cleanup Containers
</button>
@else
<x-forms.button isError class="shrink-0"
@click="document.getElementById('service-stop-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
<path
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
</svg>
Stop
</x-forms.button>
<button type="button" class="button shrink-0"
@click="document.getElementById('service-start-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>
Deploy
</button>
<button type="button" class="button shrink-0"
@click="document.getElementById('service-forceDeploy-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>
Force Deploy
</button>
<button type="button" class="button shrink-0"
@click="document.getElementById('service-cleanup-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
<path
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
</svg>
Force Cleanup Containers
</button>
@endif
</div>
</div>
@endif
<label id="service-mobile-section-label" for="service-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
<select id="service-mobile-section" class="select w-full" aria-label="Service menu" <select id="service-mobile-section" class="select w-full" aria-label="Service menu"
data-current-value="{{ $activeServiceMobileValue }}" data-current-value="{{ $activeServiceMobileValue }}"
x-data="{ x-data="{
@ -176,28 +341,6 @@
@endif @endif
@endforelse @endforelse
</optgroup> </optgroup>
@if ($service->isDeployable)
<optgroup label="Actions">
@if (str($service->status)->contains('running'))
<option value="action:restart">Restart</option>
<option value="action:stop">Stop</option>
<option value="action:pullAndRestart">Pull Latest Images & Restart</option>
@elseif (str($service->status)->contains('degraded'))
<option value="action:restart">Restart</option>
<option value="action:stop">Stop</option>
<option value="action:forceDeploy">Force Restart</option>
@elseif (str($service->status)->contains('exited'))
<option value="action:start">Deploy</option>
<option value="action:forceDeploy">Force Deploy</option>
<option value="action:cleanup">Force Cleanup Containers</option>
@else
<option value="action:stop">Stop</option>
<option value="action:start">Deploy</option>
<option value="action:forceDeploy">Force Deploy</option>
<option value="action:cleanup">Force Cleanup Containers</option>
@endif
</optgroup>
@endif
</select> </select>
</div> </div>
<nav <nav

View file

@ -230,15 +230,37 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
]" confirmationText="{{ currentTeam()->name }}" ]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm" confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" /> shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" />
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately" @if ($isRefundEligible)
isErrorButton submitAction="cancelImmediately" <div wire:key="cancel-immediately-refundable">
:actions="[ <x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
'Your subscription will be cancelled immediately.', isErrorButton submitAction="cancelImmediately"
'All servers will be deactivated.', :checkboxes="[
'No refund will be issued for the remaining period.', [
]" confirmationText="{{ currentTeam()->name }}" 'id' => 'refundLatestPayment',
confirmationLabel="Enter your team name to confirm" 'label' => 'Refund my latest payment (eligible for '.$refundDaysRemaining.' more days).',
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" /> 'default_warning' => 'No refund will be issued for the remaining period.',
],
]"
:actions="[
'Your subscription will be cancelled immediately.',
'All servers will be deactivated.',
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
</div>
@else
<div wire:key="cancel-immediately-standard">
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
isErrorButton submitAction="cancelImmediately"
:actions="[
'Your subscription will be cancelled immediately.',
'All servers will be deactivated.',
'No refund will be issued for the remaining period.',
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
</div>
@endif
@endif @endif
</div> </div>
@if (currentTeam()->subscription->stripe_cancel_at_period_end) @if (currentTeam()->subscription->stripe_cancel_at_period_end)
@ -249,7 +271,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
{{-- Refund --}} {{-- Refund --}}
<section> <section>
<h3 class="pb-2">Refund</h3> <h3 class="pb-2">Refund</h3>
@if ($refundCheckLoading || ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)) @if ($refundCheckLoading || $isRefundEligible)
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@if ($refundCheckLoading) @if ($refundCheckLoading)
<x-forms.button disabled>Request Full Refund</x-forms.button> <x-forms.button disabled>Request Full Refund</x-forms.button>
@ -269,7 +291,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
<p class="mt-2 text-sm text-neutral-500"> <p class="mt-2 text-sm text-neutral-500">
@if ($refundCheckLoading) @if ($refundCheckLoading)
Checking refund eligibility... Checking refund eligibility...
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) @elseif ($isRefundEligible)
Eligible for a full refund &mdash; <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining. Eligible for a full refund &mdash; <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.
@elseif ($refundAlreadyUsed) @elseif ($refundAlreadyUsed)
Refund already processed. Each team is eligible for one refund only. Refund already processed. Each team is eligible for one refund only.

View file

@ -36,10 +36,25 @@
->toContain('class="flex flex-col font-logs"') ->toContain('class="flex flex-col font-logs"')
->toContain('class="font-logs text-neutral-400 mb-2"') ->toContain('class="font-logs text-neutral-400 mb-2"')
->and($activityMonitorView) ->and($activityMonitorView)
->toContain('<pre class="font-logs whitespace-pre-wrap"') ->toContain('<pre class="font-logs min-w-0 max-w-full whitespace-pre-wrap wrap-anywhere"')
->and($dockerCleanupView) ->and($dockerCleanupView)
->toContain('class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300"') ->toContain('class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300"')
->toContain('class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap"') ->toContain('class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap"')
->and($terminalClient) ->and($terminalClient)
->toContain('"Geist Mono"'); ->toContain('"Geist Mono"');
}); });
it('constrains activity monitor logs inside the available viewport', function () {
$activityMonitorView = file_get_contents(resource_path('views/livewire/activity-monitor.blade.php'));
expect($activityMonitorView)
->toContain('flex flex-col w-full min-w-0 max-w-full')
->toContain('<pre class="font-logs min-w-0 max-w-full whitespace-pre-wrap wrap-anywhere"');
});
it('bounds database startup activity monitor to the slide over height', function () {
$databaseHeadingView = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
expect($databaseHeadingView)
->toContain('<div wire:ignore class="h-full min-h-0 min-w-0 max-w-full">');
});

View file

@ -4,18 +4,44 @@
$applicationHeading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); $applicationHeading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php')); $databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); $serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$applicationMobileActions = mobileActionsMarkup($applicationHeading, 'application-mobile-actions', 'application-mobile-section');
$databaseMobileActions = mobileActionsMarkup($databaseHeading, 'database-mobile-actions', 'database-mobile-section');
$serviceMobileActions = mobileActionsMarkup($serviceHeading, 'service-mobile-actions', 'service-mobile-section');
expect(mobileActionsAreBeforeSelect($applicationHeading, 'application-mobile-actions', 'application-mobile-section'))->toBeTrue();
expect(mobileActionsAreBeforeSelect($databaseHeading, 'database-mobile-actions', 'database-mobile-section'))->toBeTrue();
expect(mobileActionsAreBeforeSelect($serviceHeading, 'service-mobile-actions', 'service-mobile-section'))->toBeTrue();
expect($applicationHeading) expect($applicationHeading)
->toContain('application-mobile-actions')
->toContain("'route' => 'project.application.command'") ->toContain("'route' => 'project.application.command'")
->toContain("'navigate' => false") ->toContain("'navigate' => false")
->toContain("value.startsWith('location|')") ->toContain("value.startsWith('location|')")
->toContain('window.location.href = url'); ->toContain('window.location.href = url')
->toContain('application-mobile-stop-trigger')
->toContain('application-mobile-deploy-trigger')
->toContain('application-mobile-restart-trigger')
->toContain('application-mobile-force-deploy-trigger')
->not->toContain('<optgroup label="Actions">');
expect($applicationMobileActions)
->toContain('mb-3')
->toContain('Actions')
->toContain('<x-forms.button isError class="shrink-0"')
->not->toContain('button type="button" class="button shrink-0 text-error"')
->toContain('M7 4v16l13 -8z')
->toContain('M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747')
->toContain('M6 5m0 1a1 1 0 0 1 1 -1h2');
expect($applicationHeading)
->toContain('application-mobile-section-label')
->toContain('Section');
expect($databaseHeading) expect($databaseHeading)
->toContain('database-mobile-section') ->toContain('database-mobile-section')
->toContain('database-mobile-actions')
->toContain('<optgroup label="Database">') ->toContain('<optgroup label="Database">')
->toContain('<optgroup label="Configuration">') ->toContain('<optgroup label="Configuration">')
->toContain('<optgroup label="Actions">')
->toContain("'route' => 'project.database.command'") ->toContain("'route' => 'project.database.command'")
->toContain("'navigate' => false") ->toContain("'navigate' => false")
->toContain("value.startsWith('location|')") ->toContain("value.startsWith('location|')")
@ -25,17 +51,32 @@
->toContain('x-model="selected"') ->toContain('x-model="selected"')
->toContain('database-restart-trigger') ->toContain('database-restart-trigger')
->toContain('database-stop-trigger') ->toContain('database-stop-trigger')
->toContain('database-start-trigger')
->toContain('scrollbar hidden min-h-10') ->toContain('scrollbar hidden min-h-10')
->not->toContain('<optgroup label="Links">') ->not->toContain('<optgroup label="Links">')
->not->toContain('<optgroup label="Actions">')
->not->toContain('@selected'); ->not->toContain('@selected');
expect($databaseMobileActions)
->toContain('mb-3')
->toContain('Actions')
->toContain('<x-forms.button isError class="shrink-0"')
->not->toContain('button type="button" class="button shrink-0 text-error"')
->toContain('M7 4v16l13 -8z')
->toContain('M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747')
->toContain('M6 5m0 1a1 1 0 0 1 1 -1h2');
expect($databaseHeading)
->toContain('database-mobile-section-label')
->toContain('Section');
expect($serviceHeading) expect($serviceHeading)
->toContain('service-mobile-section') ->toContain('service-mobile-section')
->toContain('service-mobile-actions')
->toContain('<optgroup label="Service">') ->toContain('<optgroup label="Service">')
->toContain('<optgroup label="Configuration">') ->toContain('<optgroup label="Configuration">')
->toContain('<optgroup label="Resource">') ->toContain('<optgroup label="Resource">')
->toContain('<optgroup label="Links">') ->toContain('<optgroup label="Links">')
->toContain('<optgroup label="Actions">')
->toContain("'route' => 'project.service.command'") ->toContain("'route' => 'project.service.command'")
->toContain("'navigate' => false") ->toContain("'navigate' => false")
->toContain("value.startsWith('location|')") ->toContain("value.startsWith('location|')")
@ -50,10 +91,46 @@
->toContain('scrollbar hidden min-h-10') ->toContain('scrollbar hidden min-h-10')
->toContain('mb-4 w-full md:mb-0 md:hidden') ->toContain('mb-4 w-full md:mb-0 md:hidden')
->toContain('hidden flex-wrap items-center gap-2 md:flex') ->toContain('hidden flex-wrap items-center gap-2 md:flex')
->toContain('flex flex-nowrap')
->toContain('overflow-x-auto')
->not->toContain('<optgroup label="Actions">')
->not->toContain('order-first flex flex-wrap items-center gap-2 sm:order-last') ->not->toContain('order-first flex flex-wrap items-center gap-2 sm:order-last')
->not->toContain('@selected'); ->not->toContain('@selected');
expect($serviceMobileActions)
->toContain('mb-3')
->toContain('Actions')
->toContain('<x-forms.button isError class="shrink-0"')
->not->toContain('button type="button" class="button shrink-0 text-error"')
->toContain('M7 4v16l13 -8z')
->toContain('M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747')
->toContain('M6 5m0 1a1 1 0 0 1 1 -1h2');
expect($serviceHeading)
->toContain('service-mobile-section-label')
->toContain('Section');
}); });
function mobileActionsMarkup(string $heading, string $actionsId, string $selectId): string
{
$actionsPosition = strpos($heading, 'id="'.$actionsId.'"');
$selectPosition = strpos($heading, 'id="'.$selectId.'"');
if ($actionsPosition === false || $selectPosition === false || $actionsPosition > $selectPosition) {
return '';
}
return substr($heading, $actionsPosition, $selectPosition - $actionsPosition);
}
function mobileActionsAreBeforeSelect(string $heading, string $actionsId, string $selectId): bool
{
$actionsPosition = strpos($heading, 'id="'.$actionsId.'"');
$selectPosition = strpos($heading, 'id="'.$selectId.'"');
return $actionsPosition !== false && $selectPosition !== false && $actionsPosition < $selectPosition;
}
it('keeps configuration sidebars hidden until desktop breakpoint', function () { it('keeps configuration sidebars hidden until desktop breakpoint', function () {
expect(file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php'))) expect(file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php')))
->toContain('sub-menu-wrapper hidden md:flex'); ->toContain('sub-menu-wrapper hidden md:flex');

View file

@ -0,0 +1,72 @@
<?php
use App\Actions\Application\CleanupPreviewDeployment;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
it('cleans up a closed pull request preview when pull request comment cleanup fails', function () {
$preview = ApplicationPreview::create([
'application_id' => $this->application->id,
'pull_request_id' => 42,
'pull_request_html_url' => 'https://github.com/example/repo/pull/42',
]);
CleanupPreviewDeployment::shouldRun()
->once()
->withArgs(fn (Application $application, int $pullRequestId, ApplicationPreview $applicationPreview): bool => $application->is($this->application)
&& $pullRequestId === 42
&& $applicationPreview->is($preview))
->andReturn([
'cancelled_deployments' => 0,
'killed_containers' => 0,
'status' => 'success',
]);
$job = new class(
applicationId: $this->application->id,
githubAppId: null,
action: 'closed',
pullRequestId: 42,
pullRequestHtmlUrl: 'https://github.com/example/repo/pull/42',
pullRequestTitle: null,
beforeSha: null,
afterSha: null,
commitSha: 'HEAD',
authorAssociation: 'OWNER',
fullName: 'example/repo',
) extends ProcessGithubPullRequestWebhook
{
protected function dispatchPullRequestClosedUpdate(Application $application, ApplicationPreview $preview): void
{
throw new RuntimeException('GitHub comment cleanup failed.');
}
};
$job->handle();
});

View file

@ -0,0 +1,47 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function serviceExtraFieldsTestServiceWithApplicationImage(string $image): Service
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$server = Server::factory()->create();
$destination = StandaloneDocker::factory()->create(['server_id' => $server->id]);
$service = Service::factory()->create([
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
]);
$service->applications()->create([
'name' => 'app',
'image' => $image,
]);
return $service;
}
it('only adds Grafana extra fields for Grafana server images', function (string $image, bool $shouldHaveGrafanaFields) {
$fields = serviceExtraFieldsTestServiceWithApplicationImage($image)->extraFields();
expect($fields->has('Grafana'))->toBe($shouldHaveGrafanaFields);
})->with([
'grafana oss' => ['grafana/grafana-oss:latest', true],
'grafana enterprise' => ['grafana/grafana-enterprise:latest', true],
'grafana default' => ['grafana/grafana:latest', true],
'loki' => ['grafana/loki:latest', false],
'promtail' => ['grafana/promtail:latest', false],
'tempo' => ['grafana/tempo:latest', false],
]);

View file

@ -5,6 +5,7 @@
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Exception\InvalidRequestException;
use Stripe\Service\InvoiceService; use Stripe\Service\InvoiceService;
use Stripe\Service\RefundService; use Stripe\Service\RefundService;
use Stripe\Service\SubscriptionService; use Stripe\Service\SubscriptionService;
@ -85,6 +86,28 @@
expect($result['current_period_end'])->toBe($periodEnd); expect($result['current_period_end'])->toBe($periodEnd);
}); });
test('returns eligible when subscription is set to cancel at period end', function () {
$this->subscription->update(['stripe_cancel_at_period_end' => true]);
$periodEnd = now()->addDays(20)->timestamp;
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => $periodEnd,
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andReturn($stripeSubscription);
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team);
expect($result['eligible'])->toBeTrue();
expect($result['days_remaining'])->toBe(20);
});
test('returns ineligible when subscription is not active', function () { test('returns ineligible when subscription is not active', function () {
$periodEnd = now()->addDays(25)->timestamp; $periodEnd = now()->addDays(25)->timestamp;
$stripeSubscription = (object) [ $stripeSubscription = (object) [
@ -141,7 +164,7 @@
$this->mockSubscriptions $this->mockSubscriptions
->shouldReceive('retrieve') ->shouldReceive('retrieve')
->with('sub_test_123') ->with('sub_test_123')
->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription')); ->andThrow(new InvalidRequestException('No such subscription'));
$action = new RefundSubscription($this->mockStripe); $action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team); $result = $action->checkEligibility($this->team);
@ -269,6 +292,7 @@
$stripeSubscription = (object) [ $stripeSubscription = (object) [
'status' => 'active', 'status' => 'active',
'start_date' => now()->subDays(10)->timestamp, 'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => now()->addDays(20)->timestamp,
]; ];
$this->mockSubscriptions $this->mockSubscriptions
@ -298,7 +322,7 @@
$this->mockSubscriptions $this->mockSubscriptions
->shouldReceive('cancel') ->shouldReceive('cancel')
->with('sub_test_123') ->with('sub_test_123')
->andThrow(new \Exception('Stripe cancel API error')); ->andThrow(new Exception('Stripe cancel API error'));
$action = new RefundSubscription($this->mockStripe); $action = new RefundSubscription($this->mockStripe);
$result = $action->execute($this->team); $result = $action->execute($this->team);

View file

@ -0,0 +1,70 @@
<?php
use App\Actions\Stripe\RefundSubscription;
use App\Livewire\Subscription\Actions;
use App\Models\InstanceSettings;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
config()->set('subscription.stripe_api_key', 'sk_test_fake');
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_test_123',
'stripe_customer_id' => 'cus_test_123',
'stripe_invoice_paid' => true,
'stripe_plan_id' => 'price_test_123',
'stripe_cancel_at_period_end' => false,
'stripe_past_due' => false,
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('cancelImmediately with refund option', function () {
test('refunds and cancels via RefundSubscription when refund checkbox is selected', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldReceive('execute')->once()->andReturn(['success' => true, 'error' => null]);
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'password', ['refundLatestPayment'])
->assertDispatched('success')
->assertRedirect(route('subscription.index'));
});
test('dispatches error when refund fails', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldReceive('execute')->once()->andReturn(['success' => false, 'error' => 'No paid invoice found to refund.']);
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'password', ['refundLatestPayment'])
->assertDispatched('error');
});
test('rejects invalid password before refunding', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldNotReceive('execute');
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'wrong-password', ['refundLatestPayment'])
->assertReturned('Invalid password.');
});
});

View file

@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.1.2" "version": "4.2.0"
}, },
"nightly": { "nightly": {
"version": "4.2.0" "version": "4.2.1"
}, },
"helper": { "helper": {
"version": "1.0.14" "version": "1.0.14"