diff --git a/src/Illuminate/Foundation/AliasLoader.php b/src/Illuminate/Foundation/AliasLoader.php index 14623ca8..7270e9f6 100755 --- a/src/Illuminate/Foundation/AliasLoader.php +++ b/src/Illuminate/Foundation/AliasLoader.php @@ -28,7 +28,7 @@ class AliasLoader /** * The singleton instance of the loader. * - * @var \Illuminate\Foundation\AliasLoader + * @var \Illuminate\Foundation\AliasLoader|null */ protected static $instance; diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index f9331abe..cbb0b065 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -35,6 +35,7 @@ use Symfony\Component\HttpKernel\HttpKernelInterface; use function Illuminate\Filesystem\join_paths; +use function Illuminate\Support\enum_value; class Application extends Container implements ApplicationContract, CachesConfiguration, CachesRoutes, HttpKernelInterface { @@ -45,7 +46,7 @@ class Application extends Container implements ApplicationContract, CachesConfig * * @var string */ - const VERSION = '13.12.0'; + const VERSION = '13.39.0'; /** * The base path for the Laravel installation. @@ -190,7 +191,7 @@ class Application extends Container implements ApplicationContract, CachesConfig /** * The application namespace. * - * @var string + * @var string|null */ protected $namespace; @@ -208,6 +209,13 @@ class Application extends Container implements ApplicationContract, CachesConfig */ protected $absoluteCachePathPrefixes = ['/', '\\']; + /** + * The application builder class. + * + * @var class-string + */ + protected static string $applicationBuilder = Configuration\ApplicationBuilder::class; + /** * Create a new Illuminate application instance. * @@ -238,7 +246,7 @@ public static function configure(?string $basePath = null) default => static::inferBasePath(), }; - return (new Configuration\ApplicationBuilder(new static($basePath))) + return (new static::$applicationBuilder(new static($basePath))) ->withKernels() ->withEvents() ->withCommands() @@ -317,12 +325,12 @@ protected function registerLaravelCloudServices() $this['events']->listen( 'bootstrapping: *', - fn ($bootstrapper) => Cloud::bootstrapperBootstrapping($this, Str::after($bootstrapper, 'bootstrapping: ')) + fn ($bootstrapper) => CloudBootstrapper::bootstrapping($this, Str::after($bootstrapper, 'bootstrapping: ')) ); $this['events']->listen( 'bootstrapped: *', - fn ($bootstrapper) => Cloud::bootstrapperBootstrapped($this, Str::after($bootstrapper, 'bootstrapped: ')) + fn ($bootstrapper) => CloudBootstrapper::bootstrapped($this, Str::after($bootstrapper, 'bootstrapped: ')) ); } @@ -743,7 +751,7 @@ public function environmentFilePath() /** * Get or check the current application environment. * - * @param string|array ...$environments + * @param \UnitEnum|string|array ...$environments * @return string|bool */ public function environment(...$environments) @@ -751,7 +759,7 @@ public function environment(...$environments) if ($environments !== []) { $patterns = is_array($environments[0]) ? $environments[0] : $environments; - return Str::is($patterns, $this['env']); + return Str::is(array_map(enum_value(...), $patterns), $this['env']); } return $this['env']; @@ -1658,6 +1666,7 @@ public function registerCoreContainerAliases() 'filesystem.cloud' => [\Illuminate\Contracts\Filesystem\Cloud::class], 'hash' => [\Illuminate\Hashing\HashManager::class], 'hash.driver' => [\Illuminate\Contracts\Hashing\Hasher::class], + 'image' => [\Illuminate\Image\ImageManager::class], 'log' => [\Illuminate\Log\LogManager::class, \Psr\Log\LoggerInterface::class], 'mail.manager' => [\Illuminate\Mail\MailManager::class, \Illuminate\Contracts\Mail\Factory::class], 'mailer' => [\Illuminate\Mail\Mailer::class, \Illuminate\Contracts\Mail\Mailer::class, \Illuminate\Contracts\Mail\MailQueue::class], diff --git a/src/Illuminate/Foundation/ArrayMaintenanceMode.php b/src/Illuminate/Foundation/ArrayMaintenanceMode.php new file mode 100644 index 00000000..b9f9065a --- /dev/null +++ b/src/Illuminate/Foundation/ArrayMaintenanceMode.php @@ -0,0 +1,65 @@ +active = true; + $this->payload = $payload; + } + + /** + * Take the application out of maintenance. + * + * @return void + */ + public function deactivate(): void + { + $this->active = false; + $this->payload = []; + } + + /** + * Determine if the application is currently down for maintenance. + * + * @return bool + */ + public function active(): bool + { + return $this->active; + } + + /** + * Get the data array which was provided when the application was placed into maintenance. + * + * @return array + */ + public function data(): array + { + return $this->payload; + } +} diff --git a/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php b/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php index a5588cf0..0e152cc2 100644 --- a/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php +++ b/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php @@ -28,7 +28,7 @@ class HandleExceptions /** * The application instance. * - * @var \Illuminate\Contracts\Foundation\Application + * @var \Illuminate\Contracts\Foundation\Application|null */ protected static $app; @@ -92,25 +92,31 @@ public function handleDeprecationError($message, $file, $line, $level = E_DEPREC return; } - try { - $logger = static::$app->make(LogManager::class); - } catch (Exception) { + if (! static::$app->bound('config')) { return; } - $this->ensureDeprecationLoggerIsConfigured(); + try { + $logger = static::$app->make(LogManager::class); - $options = static::$app['config']->get('logging.deprecations') ?? []; + $this->ensureDeprecationLoggerIsConfigured(); - with($logger->channel('deprecations'), function ($log) use ($message, $file, $line, $level, $options) { - if ($options['trace'] ?? false) { - $log->warning((string) new ErrorException($message, 0, $level, $file, $line)); - } else { - $log->warning(sprintf('%s in %s on line %s', - $message, $file, $line - )); - } - }); + $options = static::$app['config']->get('logging.deprecations') ?? []; + + with($logger->channel('deprecations'), function ($log) use ($message, $file, $line, $level, $options) { + if ($options['trace'] ?? false) { + $log->warning($message, [ + 'exception' => new ErrorException($message, 0, $level, $file, $line), + ]); + } else { + $log->warning(sprintf('%s in %s on line %s', + $message, $file, $line + )); + } + }); + } catch (Throwable) { + return; + } } /** @@ -121,6 +127,7 @@ public function handleDeprecationError($message, $file, $line, $level = E_DEPREC protected function shouldIgnoreDeprecationErrors() { return ! class_exists(LogManager::class) + || is_null(static::$app) || ! static::$app->hasBeenBootstrapped() || (static::$app->runningUnitTests() && ! Env::get('LOG_DEPRECATIONS_WHILE_TESTING')); } diff --git a/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php b/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php index 37229ac4..87d13568 100644 --- a/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php +++ b/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php @@ -34,7 +34,7 @@ public function bootstrap(Application $app) // we will need to spin through every configuration file and load them all. $loadedFromCache = false; - if (self::$alwaysUseConfig !== null) { + if (static::$alwaysUseConfig !== null) { $items = $app->call(self::$alwaysUseConfig); $loadedFromCache = true; diff --git a/src/Illuminate/Foundation/Bus/PendingDispatch.php b/src/Illuminate/Foundation/Bus/PendingDispatch.php index 462565d5..1829bbf1 100644 --- a/src/Illuminate/Foundation/Bus/PendingDispatch.php +++ b/src/Illuminate/Foundation/Bus/PendingDispatch.php @@ -12,8 +12,10 @@ use Illuminate\Foundation\Queue\InteractsWithUniqueJobs; use Illuminate\Queue\Attributes\DebounceFor; use Illuminate\Queue\Attributes\ReadsQueueAttributes; +use Illuminate\Queue\Events\UniqueJobSkipped; use Illuminate\Support\Traits\Conditionable; use LogicException; +use Throwable; class PendingDispatch { @@ -215,8 +217,19 @@ protected function shouldDispatch() return true; } - return (new UniqueLock(Container::getInstance()->make(Cache::class))) - ->acquire($this->job); + $container = Container::getInstance(); + + $lockAcquired = (new UniqueLock($container->make(Cache::class)))->acquire($this->job); + + if ($lockAcquired) { + return true; + } + + if ($container->bound('events')) { + $container->make('events')->dispatch(new UniqueJobSkipped($this->job)); + } + + return false; } /** @@ -282,22 +295,28 @@ public function __call($method, $parameters) */ public function __destruct() { - $this->addUniqueJobInformationToContext($this->job); - if (! $this->shouldDispatch()) { - $this->removeUniqueJobInformationFromContext($this->job); - return; } - $this->acquireDebounceLock(); + $this->addUniqueJobInformationToContext($this->job); - if ($this->afterResponse) { - app(Dispatcher::class)->dispatchAfterResponse($this->job); - } else { - app(Dispatcher::class)->dispatch($this->job); + try { + $this->acquireDebounceLock(); + + if ($this->afterResponse) { + app(Dispatcher::class)->dispatchAfterResponse($this->job); + } else { + app(Dispatcher::class)->dispatch($this->job); + } + } catch (Throwable $e) { + if ($this->job instanceof ShouldBeUnique) { + (new UniqueLock(Container::getInstance()->make(Cache::class)))->release($this->job); + } + + throw $e; + } finally { + $this->removeUniqueJobInformationFromContext($this->job); } - - $this->removeUniqueJobInformationFromContext($this->job); } } diff --git a/src/Illuminate/Foundation/Cloud.php b/src/Illuminate/Foundation/Cloud.php deleted file mode 100644 index 7f9b98b5..00000000 --- a/src/Illuminate/Foundation/Cloud.php +++ /dev/null @@ -1,211 +0,0 @@ - function () use ($app) { - static::bootManagedQueues($app); - }, - default => fn () => true, - })(); - } - - /** - * Handle a bootstrapper that has bootstrapped. - */ - public static function bootstrapperBootstrapped(Application $app, string $bootstrapper): void - { - (match ($bootstrapper) { - LoadConfiguration::class => function () use ($app) { - static::configureDisks($app); - static::configureUnpooledPostgresConnection($app); - static::ensureMigrationsUseUnpooledConnection($app); - static::configureManagedQueues($app); - }, - HandleExceptions::class => function () use ($app) { - static::configureCloudLogging($app); - }, - default => fn () => true, - })(); - } - - /** - * Configure the Laravel Cloud disks if applicable. - */ - public static function configureDisks(Application $app): void - { - if (! isset($_SERVER['LARAVEL_CLOUD_DISK_CONFIG'])) { - return; - } - - $disks = json_decode($_SERVER['LARAVEL_CLOUD_DISK_CONFIG'], true); - - foreach ($disks as $disk) { - if ($disk['scoped_disk'] ?? false) { - $app['config']->set('filesystems.disks.'.$disk['disk'], [ - 'driver' => 'scoped', - 'disk' => $disk['scoped_disk'], - 'prefix' => $disk['prefix'] ?? '', - ]); - } else { - $app['config']->set('filesystems.disks.'.$disk['disk'], [ - 'driver' => 's3', - 'key' => $disk['access_key_id'], - 'secret' => $disk['access_key_secret'], - 'bucket' => $disk['bucket'], - 'url' => $disk['url'], - 'endpoint' => $disk['endpoint'], - 'region' => 'auto', - 'use_path_style_endpoint' => false, - 'throw' => false, - 'report' => false, - ]); - } - - if ($disk['is_default'] ?? false) { - $app['config']->set('filesystems.default', $disk['disk']); - } - } - } - - /** - * Configure the unpooled Laravel Postgres connection if applicable. - */ - public static function configureUnpooledPostgresConnection(Application $app): void - { - $host = $app['config']->get('database.connections.pgsql.host', ''); - - if (str_contains($host, 'pg.laravel.cloud') && - str_contains($host, '-pooler')) { - $app['config']->set( - 'database.connections.pgsql-unpooled', - array_merge($app['config']->get('database.connections.pgsql'), [ - 'host' => str_replace('-pooler', '', $host), - ]) - ); - - $app['config']->set( - 'database.connections.pgsql.options', - array_merge( - $app['config']->get('database.connections.pgsql.options', []), - [PDO::ATTR_EMULATE_PREPARES => true], - ), - ); - } - } - - /** - * Ensure that migrations use the unpooled Postgres connection if applicable. - */ - public static function ensureMigrationsUseUnpooledConnection(Application $app): void - { - if (! is_array($app['config']->get('database.connections.pgsql-unpooled'))) { - return; - } - - Migrator::resolveConnectionsUsing(function ($resolver, $connection) use ($app) { - $connection ??= $app['config']->get('database.default'); - - return $resolver->connection( - $connection === 'pgsql' ? 'pgsql-unpooled' : $connection - ); - }); - } - - /** - * Configure managed queues if applicable. - */ - public static function configureManagedQueues(Application $app): void - { - if (! isset($_SERVER['LARAVEL_CLOUD_MANAGED_QUEUES_CONFIG'])) { - return; - } - - $config = json_decode($_SERVER['LARAVEL_CLOUD_MANAGED_QUEUES_CONFIG'], associative: true, flags: JSON_THROW_ON_ERROR); - - $config['connection']['after_commit'] ??= env('CLOUD_QUEUE_AFTER_COMMIT', false); - - $config['connection']['overflow'] ??= [ - 'enabled' => env('CLOUD_QUEUE_OVERFLOW_ENABLED', false), - 'store' => env('CLOUD_QUEUE_OVERFLOW_STORE'), - 'always' => env('CLOUD_QUEUE_OVERFLOW_ALWAYS', false), - 'delete_after_processing' => env('CLOUD_QUEUE_OVERFLOW_DELETE_AFTER_PROCESSING', true), - ]; - - $app['config']->set('queue.connections.cloud', $config); - } - - /** - * Boot managed queues if applicable. - */ - public static function bootManagedQueues(Application $app): void - { - if ($app['config']->get('queue.connections.cloud.driver') !== 'cloud') { - return; - } - - $app->singleton(Events::class, fn () => new Events(Cloud::socket())); - $app->bind(QueueConnector::class, fn ($app) => new QueueConnector(new SqsConnector, $app)); - - $app['queue']->addConnector('cloud', $app->factory(QueueConnector::class)); - - $failer = $app['queue.failer']; - unset($app['queue.failer']); - - $app->singleton('queue.failer', fn ($app) => new FailedJobProvider( - $failer, $app[Events::class], $app['encrypter'], - )); - } - - /** - * Configure the Laravel Cloud log channels. - */ - public static function configureCloudLogging(Application $app): void - { - $app['config']->set('logging.channels.stderr.formatter_with', [ - 'includeStacktraces' => true, - ]); - - $app['config']->set('logging.channels.laravel-cloud-socket', [ - 'driver' => 'monolog', - 'level' => $_ENV['LOG_LEVEL'] ?? $_SERVER['LOG_LEVEL'] ?? 'debug', - 'handler' => SocketHandler::class, - 'formatter' => LaravelCloudJsonFormatter::class, - 'formatter_with' => [ - 'includeStacktraces' => true, - ], - 'with' => [ - 'connectionString' => Cloud::socket(), - 'persistent' => true, - ], - ]); - } - - /** - * The cloud socket address. - */ - protected static function socket(): string - { - return $_ENV['LARAVEL_CLOUD_LOG_SOCKET'] ?? - $_SERVER['LARAVEL_CLOUD_LOG_SOCKET'] ?? - 'unix:///tmp/cloud-init.sock'; - } -} diff --git a/src/Illuminate/Foundation/Cloud/AgentAwareLostConnectionDetector.php b/src/Illuminate/Foundation/Cloud/AgentAwareLostConnectionDetector.php new file mode 100644 index 00000000..6797c009 --- /dev/null +++ b/src/Illuminate/Foundation/Cloud/AgentAwareLostConnectionDetector.php @@ -0,0 +1,32 @@ +detector->causedByLostConnection($e); + } +} diff --git a/src/Illuminate/Foundation/Cloud/AgentUnreachableException.php b/src/Illuminate/Foundation/Cloud/AgentUnreachableException.php new file mode 100644 index 00000000..96dc3cb9 --- /dev/null +++ b/src/Illuminate/Foundation/Cloud/AgentUnreachableException.php @@ -0,0 +1,10 @@ +report('processed'); + + // Only reached once the agent has accepted the outcome (report() throws otherwise)... + $this->deleteOverflowPayload(); + } + + /** + * Release the job back into the queue after (n) seconds. + * + * @param int $delay + * @return void + */ + public function release($delay = 0) + { + // Skip SQS deletion so SQS release is left to the poller... + Job::release($delay); + + $this->report('released', delay: $delay); + } + + /** + * Report the job's outcome to the agent, which owns the SQS operation. + */ + protected function report(string $status, ?int $delay = null): void + { + ($this->reporter)($status, $delay); + } +} diff --git a/src/Illuminate/Foundation/Cloud/CloudManager.php b/src/Illuminate/Foundation/Cloud/CloudManager.php new file mode 100644 index 00000000..eaedb04c --- /dev/null +++ b/src/Illuminate/Foundation/Cloud/CloudManager.php @@ -0,0 +1,57 @@ +container->make('config')->get('queue.connections.cloud.driver') === 'cloud'; + } + + /** + * Get the Laravel Cloud managed queue connection. + * + * @throws \RuntimeException + */ + public function queue(): Queue + { + if (! $this->usesManagedQueues()) { + throw new RuntimeException('Laravel Cloud managed queues are not configured for this application.'); + } + + return $this->container->make('queue')->connection('cloud'); + } + + /** + * Determine if the given queue is managed by Laravel Cloud. + */ + public function isManagedQueue(string $queue): bool + { + return $this->usesManagedQueues() && in_array($queue, $this->queue()->managedQueues(), true); + } +} diff --git a/src/Illuminate/Foundation/Cloud/Events.php b/src/Illuminate/Foundation/Cloud/Events.php index 2d90ccad..144b5fda 100644 --- a/src/Illuminate/Foundation/Cloud/Events.php +++ b/src/Illuminate/Foundation/Cloud/Events.php @@ -2,7 +2,6 @@ namespace Illuminate\Foundation\Cloud; -use Illuminate\Foundation\Cloud; use RuntimeException; use Throwable; @@ -15,6 +14,13 @@ class Events */ protected $socket = null; + /** + * The cloud socket factory. + * + * @var callable|null + */ + public static $socketFactory = null; + /** * Create a new instance. */ @@ -28,9 +34,13 @@ public function __construct(protected string $address) * * @param array $payload */ - public function emit(array $payload): void + public function emit(array $payload): bool { - $this->emitMany([$payload]); + if ($payload === []) { + return true; + } + + return $this->emitMany([$payload]); } /** @@ -38,25 +48,25 @@ public function emit(array $payload): void * * @param list> $payloads */ - public function emitMany(array $payloads): void + public function emitMany(array $payloads): bool { if ($payloads === []) { - return; + return true; } try { $this->ensureConnected(); $this->write($this->format($payloads)); + + return true; } catch (Throwable) { - // + return false; } } /** * Write the payload to the socket. - * - * @param list> $payloads */ protected function write(string $payload): void { @@ -101,6 +111,8 @@ protected function write(string $payload): void * Format the payload. * * @param list> $payloads + * + * @throws \JsonException */ protected function format(array $payloads): string { @@ -128,7 +140,12 @@ protected function ensureConnected(): void */ protected function connect(): void { - $socket = stream_socket_client( + $factory = static::$socketFactory ?? stream_socket_client(...); + + $errorCode = null; + $errorMessage = null; + + $socket = $factory( address: $this->address, error_code: $errorCode, error_message: $errorMessage, diff --git a/src/Illuminate/Foundation/Cloud/ExceptionReporter.php b/src/Illuminate/Foundation/Cloud/ExceptionReporter.php new file mode 100644 index 00000000..056f4798 --- /dev/null +++ b/src/Illuminate/Foundation/Cloud/ExceptionReporter.php @@ -0,0 +1,1355 @@ + + */ + protected WeakMap $exceptionIds; + + /** + * The time the Octane request was received. + */ + protected ?float $octaneRequestReceivedAt = null; + + /** + * The name of the currently running Artisan command. + */ + protected ?string $currentlyRunningCommandName = null; + + /** + * The trace ID for the current artisan command execution. + */ + protected ?string $artisanCommandTraceId = null; + + /** + * The console input of the currently running Artisan command. + */ + protected ?ConsoleInput $currentConsoleInput = null; + + /** + * The console input built from the process arguments. Fallback when console input is not yet set. + */ + protected ?ArgvInput $currentFallbackArgvInput = null; + + /** + * The currently running scheduled task. + */ + protected ?ScheduledTask $currentlyRunningScheduledTask = null; + + /** + * Indicates the currently running scheduled task has finished with its failure yet to be reported. + */ + protected bool $scheduledTaskAwaitingFailureReport = false; + + /** + * The currently executing job on the queue. + */ + protected ?Job $currentlyProcessingJob = null; + + /** + * The currently executing job attempt ID. + */ + protected ?string $currentlyProcessingJobAttemptId = null; + + /** + * The cached normalized queue names, keyed by "connection:queue". + * + * @var array + */ + protected array $normalizedQueues = []; + + /** + * The cached queue configuration. + * + * @var array|null + */ + protected ?array $connectionConfig = null; + + /** + * Proactively captured execution context, keyed by execution type. + * + * @var array{ + * job: array, + * scheduled_task: array, + * } + */ + protected array $executionContext = [ + 'job' => [], + 'scheduled_task' => [], + ]; + + /** + * The memory reserved for reporting fatal errors. + */ + protected ?string $reservedMemory; + + /** + * Create a new Exception Reporter instance. + * + * @param array{ + * stop: bool, + * capture_request_payload: bool, + * redact_request_payload_fields: list, + * redact_headers: list, + * redact_command_input_fields: list, + * } $config + */ + public function __construct( + protected Events $events, + protected Closure $bladeMapperResolver, + protected string $basePath, + protected array $config, + ) { + $this->exceptionIds = new WeakMap; + + $this->reservedMemory = str_repeat('x', 32768); + } + + /** + * Report the given exception. + */ + public function __invoke(Throwable $e): ?bool + { + if ($e instanceof FatalError) { + $this->reservedMemory = null; + } + + $previousReportingViewException = $this->reportingViewException; + $previousBladeMapper = $this->bladeMapper; + + try { + $this->reportingViewException = $this->isViewException($e); + $this->bladeMapper = null; + + $e = $this->unwrapViewException($e); + + $emitted = $this->events->emit($this->payload($e)); + + if ($emitted === false) { + return null; + } + + return ! $this->config['stop']; + } catch (Throwable) { + return null; + } finally { + $this->reportingViewException = $previousReportingViewException; + $this->bladeMapper = $previousBladeMapper; + + if ($this->scheduledTaskAwaitingFailureReport) { + $this->flushScheduledTaskContext(); + } + } + } + + /** + * Retrieve the identifier for the given exception. + */ + public function exceptionId(Throwable $e): string + { + return $this->exceptionIds[$this->unwrapViewException($e)] ??= (string) Uuid::uuid7(); + } + + /** + * Unwrap view exceptions and remember the original exception. + */ + protected function unwrapViewException(Throwable $e): Throwable + { + while ($this->isViewException($e) && $e->getPrevious()) { + $e = $e->getPrevious(); + } + + return $e; + } + + /** + * Determine if the given exception is a view exception. + */ + protected function isViewException(Throwable $e): bool + { + return $e instanceof ViewException || $e instanceof IgnitionViewException; + } + + /** + * Create the exception payload. + * + * @return array + */ + protected function payload(Throwable $e): array + { + return [ + '_cloud_event' => 'exception', + 'id' => $this->exceptionId($e), + 'timestamp' => $this->timestamp(), + 'exception_context' => $this->exceptionContext($e), + 'laravel_context' => $this->laravelContext(), + ...$this->executionDetails($e), + 'user_id' => $this->userId(), + 'handled' => $this->handled($e), + ...$this->parseException($e), + 'previous' => $this->previous($e), + ]; + } + + /** + * Retrieve the current timestamp. + */ + protected function timestamp(): string + { + return Date::now('UTC')->toDateTimeString('microsecond'); + } + + /** + * Retrieve the context for the given exception. + */ + protected function exceptionContext(Throwable $e): ?object + { + try { + return (object) Arr::except(Exceptions::contextForException($e), 'exception'); + } catch (Throwable) { + return null; + } + } + + /** + * Retrieve the current Laravel Context. + */ + protected function laravelContext(): ?object + { + try { + return (object) Context::all(); + } catch (Throwable) { + return null; + } + } + + /** + * Retrieve the execution context. + * + * @return array + */ + protected function executionDetails(Throwable $e): array + { + try { + return match (true) { + $this->isProcessingJob() => $this->jobExecutionDetails($e), + $this->isRunningScheduledTask() => $this->scheduledTaskExecutionDetails($e), + App::runningInConsole() => $this->consoleCommandExecutionDetails($e), + default => $this->requestExecutionDetails($e), + }; + } catch (Throwable) { + return []; + } + } + + /** + * Prepare for an incoming Octane request. + */ + public function prepareForOctaneRequest(): void + { + $this->octaneRequestReceivedAt = (float) Date::now()->format('U.u'); + } + + /** + * Retrieve the request execution context. + * + * @return array + */ + protected function requestExecutionDetails(Throwable $e): array + { + return [ + 'trace_id' => Request::header('Cloud-Request-ID'), + 'execution_type' => 'request', + 'execution_context' => [ + 'timestamp' => $this->laravelStartedAtTimestamp(), + 'headers' => $this->requestHeaders(), + 'method' => Request::method(), + 'url' => $this->requestUrl(), + 'ip' => Request::ip(), + 'route' => $this->requestRouteExecutionDetails(), + 'payload' => $this->requestPayload($e), + 'files' => $this->requestFiles($e), + ], + ]; + } + + /** + * Retrieve the request headers. + * + * @return array>|null + */ + protected function requestHeaders(): ?array + { + try { + $headers = clone Request::instance()->headers; + + $this->removeSyntheticAuthorizationHeaders($headers); + $this->redactHeaders($headers); + + return $headers->all(); + } catch (Throwable) { + return null; + } + } + + /** + * Remove the headers PHP derives from the Authorization header. + */ + protected function removeSyntheticAuthorizationHeaders(HeaderBag $headers): void + { + // Remove PHP's unredacted copies of the Authorization header credentials... + $headers->remove('php-auth-user'); + $headers->remove('php-auth-pw'); + $headers->remove('php-auth-digest'); + } + + /** + * Redact the configured sensitive headers. + */ + protected function redactHeaders(HeaderBag $headers): void + { + foreach ($this->config['redact_headers'] as $key) { + if (! $headers->has($key)) { + continue; + } + + $headers->set($key, array_map(fn ($value) => match (strtolower($key)) { + 'authorization', 'proxy-authorization' => $this->redactAuthorizationHeaderValue((string) $value), + 'cookie' => $this->redactCookieHeaderValue((string) $value), + default => $this->redactValue((string) $value), + }, $headers->all($key))); + } + } + + /** + * Redact the given authorization header value, retaining the scheme. + */ + protected function redactAuthorizationHeaderValue(string $value): string + { + if (! str_contains($value, ' ')) { + return $this->redactValue($value); + } + + [$scheme, $remainder] = explode(' ', $value, 2); + + if (in_array(strtolower($scheme), [ + 'basic', + 'bearer', + 'concealed', + 'digest', + 'dpop', + 'gnap', + 'hoba', + 'mutual', + 'negotiate', + 'oauth', + 'privatetoken', + 'scram-sha-1', + 'scram-sha-256', + 'vapid', + ])) { + return $scheme.' '.$this->redactValue($remainder); + } + + return $this->redactValue($value); + } + + /** + * Redact the given cookie header value, retaining the cookie names. + */ + protected function redactCookieHeaderValue(string $value): string + { + try { + return implode('; ', array_map(function ($cookie) { + if (! str_contains($cookie, '=')) { + throw new RuntimeException('Invalid cookie format.'); + } + + [$name, $value] = explode('=', $cookie, 2); + + return trim($name).'='.$this->redactValue($value); + }, explode(';', $value))); + } catch (Throwable) { + return $this->redactValue($value); + } + } + + /** + * Retrieve the requested URL. + */ + protected function requestUrl(): string + { + $request = Request::instance(); + + $query = (string) $request->server->get('QUERY_STRING'); + + return $request->getSchemeAndHttpHost() + .$request->getBaseUrl() + .$request->getPathInfo() + .($query === '' ? '' : "?{$query}"); + } + + /** + * Retrieve the route specific request execution context. + * + * @return array|null + */ + protected function requestRouteExecutionDetails(): ?array + { + try { + $route = Request::route(); + + if (! ($route instanceof Route)) { + return null; + } + + return [ + 'name' => $route->getName(), + 'methods' => collect($route->methods()) + ->sort() + ->values() + ->all(), + 'domain' => $route->domain(), + 'path' => match ($route->uri()) { + '/' => '/', + default => "/{$route->uri()}", + }, + 'action' => $route->getActionName(), + ]; + } catch (Throwable) { + return null; + } + } + + /** + * Retrieve the request payload. + * + * @return array|null + */ + protected function requestPayload(Throwable $e): ?array + { + if (! $this->config['capture_request_payload'] || $e instanceof FatalError) { + return null; + } + + try { + return $this->redactRequestPayload(Request::instance()->request->all()); + } catch (Throwable) { + return null; + } + } + + /** + * Redact the configured sensitive fields in the given payload. + * + * @param array $payload + * @return array + */ + protected function redactRequestPayload(array $payload): array + { + return Arr::map($payload, function ($value, $key) { + if (is_array($value)) { + return $this->redactRequestPayload($value); + } + + return $this->shouldRedactRequestPayloadField($key, $value) + ? $this->redactValue($value === false ? '0' : (string) $value) + : $value; + }); + } + + /** + * Determine if the given payload field should be redacted. + */ + protected function shouldRedactRequestPayloadField(string $field, mixed $value): bool + { + return in_array($field, $this->config['redact_request_payload_fields']) + && is_scalar($value); + } + + /** + * Retrieve the parsed uploaded files. + * + * @return array|null + */ + protected function requestFiles(Throwable $e): ?array + { + if (! $this->config['capture_request_payload'] || $e instanceof FatalError) { + return null; + } + + try { + return $this->parseRequestFiles(Request::allFiles()); + } catch (Throwable) { + return null; + } + } + + /** + * Parse the given uploaded files into their reportable details. + * + * Values are uploaded files, or nested arrays of them. + * + * @param array $files + * @return array + */ + protected function parseRequestFiles(array $files): array + { + return array_map(function ($file) { + if (is_array($file)) { + return $this->parseRequestFiles($file); + } + + return [ + 'originalName' => $file->getClientOriginalName(), + 'mimeType' => $file->getMimeType(), + 'size' => $file->getSize(), + 'error' => $file->getError(), + ]; + }, $files); + } + + /** + * Prepare to process the given command. + */ + public function prepareForCommand(string $name, InputInterface $input): void + { + $this->currentlyRunningCommandName = $name !== '' + ? $name + : null; + + $this->currentConsoleInput = $input instanceof ConsoleInput ? $input : null; + } + + /** + * Retrieve the console execution context. + * + * @return array + */ + protected function consoleCommandExecutionDetails(Throwable $e): array + { + return [ + 'trace_id' => $this->consoleCommandTraceId(), + 'execution_type' => 'command', + 'execution_context' => [ + 'timestamp' => $this->laravelStartedAtTimestamp(), + 'name' => $this->consoleCommandName(), + 'class' => $this->consoleCommandClass(), + 'command' => $this->consoleCommandLine(), + ], + ]; + } + + /** + * Retrieve the console command execution trace ID. + */ + protected function consoleCommandTraceId(): string + { + if (isset($_SERVER['LARAVEL_CLOUD_COMMAND_UUID'])) { + return $this->artisanCommandTraceId ??= str($_SERVER['LARAVEL_CLOUD_COMMAND_UUID'])->after('comm-')->toString(); + } + + return $this->artisanCommandTraceId ??= (string) Uuid::uuid7(); + } + + /** + * Retrieve the name of the currently running Artisan command. + */ + protected function consoleCommandName(): ?string + { + return $this->currentlyRunningCommandName ?? $this->currentFallbackArgvInput()->getFirstArgument(); + } + + /** + * Retrieve the class name of the currently running Artisan command. + */ + protected function consoleCommandClass(): ?string + { + try { + $name = $this->consoleCommandName(); + + if ($name === null) { + return null; + } + + $command = Artisan::findCommand($name); + + return $command === null + ? null + : $command::class; + } catch (Throwable) { + return null; + } + } + + /** + * Retrieve the redacted command line. + */ + protected function consoleCommandLine(): ?string + { + try { + // Without the console input we cannot safely redact values, so report nothing... + try { + $this->currentConsoleInput(); + } catch (CommandNotFoundException) { + return null; + } + + if (! $this->currentConsoleInput() instanceof RawInputInterface) { + return null; + } + + $tokens = Arr::wrap($this->currentConsoleInput()->getFirstArgument()); + + foreach ($this->currentConsoleInput()->getRawArguments() as $name => $value) { + // Skip the initial argument, which is the command name. We have already captured that above... + if ($name === 'command' || is_int($name)) { + continue; + } + + $transformer = $this->consoleArgumentTransformer($name); + + $tokens = [ + ...$tokens, + ...$this->applyTransformationToConsoleInputValue($value, $transformer), + ]; + } + + foreach ($this->currentConsoleInput()->getRawOptions() as $name => $value) { + $transformer = $this->consoleOptionTransformer($name); + + $tokens = [ + ...$tokens, + ...$this->applyTransformationToConsoleInputValue($value, $transformer), + ]; + } + + return implode(' ', $tokens); + } catch (Throwable) { + return null; + } + } + + /** + * Retrieve a transformer for the given console argument. + */ + protected function consoleArgumentTransformer(string $name): callable + { + return $this->shouldRedactConsoleInputValue($name) + ? $this->redactValue(...) + : $this->currentConsoleInput()->escapeToken(...); + } + + /** + * Retrieve a transformer for the given console option. + */ + protected function consoleOptionTransformer(string $name): callable + { + return fn ($value) => match (true) { + is_bool($value) => $value ? "--{$name}" : "--no-{$name}", + is_null($value) => "--{$name}", + $this->shouldRedactConsoleInputValue($name) => "--{$name}={$this->redactValue($value)}", + default => "--{$name}={$this->currentConsoleInput()->escapeToken($value)}", + }; + } + + /** + * Apply the given transformation to each of the given input's values. + * + * @param callable(string): string $transformer + * @return list + */ + protected function applyTransformationToConsoleInputValue(null|bool|string|array $value, callable $transformer): array + { + return array_map($transformer, $value === null ? [null] : Arr::wrap($value)); + } + + /** + * Determine if the given command argument or option should be redacted. + */ + protected function shouldRedactConsoleInputValue(string $name): bool + { + return in_array($name, $this->config['redact_command_input_fields']); + } + + /** + * Retrieve the console input of the currently running Artisan command. + */ + protected function currentConsoleInput(): ConsoleInput + { + if ($this->currentConsoleInput !== null) { + return $this->currentConsoleInput; + } + + $name = $this->currentFallbackArgvInput()->getFirstArgument(); + + $command = $name === null + ? null + : Artisan::findCommand($name); + + if ($command === null) { + throw new CommandNotFoundException("The command [{$name}] does not exist."); + } + + $command->mergeApplicationDefinition(); + + $this->currentFallbackArgvInput()->bind($command->getDefinition()); + + return $this->currentConsoleInput = $this->currentFallbackArgvInput(); + } + + /** + * Retrieve the fallback console input built from the process arguments. + */ + protected function currentFallbackArgvInput(): ArgvInput + { + return $this->currentFallbackArgvInput ??= new ArgvInput; + } + + /** + * Prepare to run the given scheduled task. + */ + public function prepareForScheduledTask(ScheduledTask $task): void + { + $this->currentlyRunningScheduledTask = $task; + $this->scheduledTaskAwaitingFailureReport = false; + + $this->executionContext['scheduled_task'] = [ + 'timestamp' => $this->timestamp(), + ]; + } + + /** + * Handle the given scheduled task finishing. + */ + public function finishScheduledTask(ScheduledTask $task): void + { + // Failed commands are reported after finishing, so keep the task context until then... + if ($task->command !== null && $task->exitCode !== 0 && ! $task->runInBackground) { + $this->scheduledTaskAwaitingFailureReport = true; + + return; + } + + $this->flushScheduledTaskContext(); + } + + /** + * Flush the currently running scheduled task context. + */ + public function flushScheduledTaskContext(): void + { + $this->currentlyRunningScheduledTask = null; + $this->scheduledTaskAwaitingFailureReport = false; + $this->executionContext['scheduled_task'] = []; + } + + /** + * Determine if a scheduled task is running. + */ + protected function isRunningScheduledTask(): bool + { + return $this->currentlyRunningScheduledTask !== null; + } + + /** + * Retrieve the currently running scheduled task's execution context. + * + * @return array + */ + protected function scheduledTaskExecutionDetails(Throwable $e): array + { + return [ + 'trace_id' => $this->consoleCommandTraceId(), + 'execution_type' => 'scheduled_task', + 'execution_context' => [ + ...$this->executionContext['scheduled_task'], + 'name' => $this->scheduledTaskName(), + 'class' => $this->scheduledTaskClass(), + 'cron' => $this->currentlyRunningScheduledTask->expression, + 'timezone' => $this->scheduledTaskTimezone(), + 'repeat_seconds' => $this->currentlyRunningScheduledTask->repeatSeconds, + 'without_overlapping' => $this->currentlyRunningScheduledTask->withoutOverlapping, + 'on_one_server' => $this->currentlyRunningScheduledTask->onOneServer, + 'run_in_background' => $this->currentlyRunningScheduledTask->runInBackground, + 'even_in_maintenance_mode' => $this->currentlyRunningScheduledTask->evenInMaintenanceMode, + ], + ]; + } + + /** + * Retrieve the name of the currently running scheduled task. + */ + protected function scheduledTaskName(): string + { + if ($this->currentlyRunningScheduledTask instanceof CallbackTask) { + return $this->scheduledCallbackTaskName(); + } + + return str_replace([ + ConsoleApplication::phpBinary(), + ConsoleApplication::artisanBinary(), + ], [ + 'php', + preg_replace("#['\"]#", '', ConsoleApplication::artisanBinary()), + ], $this->currentlyRunningScheduledTask->command ?? ''); + } + + /** + * Retrieve the name of the currently running scheduled callback task. + */ + protected function scheduledCallbackTaskName(): string + { + $name = $this->currentlyRunningScheduledTask->getSummaryForDisplay(); + + if (! in_array($name, ['Closure', 'Callback'])) { + return $name; + } + + return match (true) { + $this->scheduledTaskCallback() instanceof Closure => $this->scheduledClosureTaskName($this->scheduledTaskCallback()), + is_string($this->scheduledTaskCallback()) => $this->scheduledTaskCallback(), + is_array($this->scheduledTaskCallback()) => is_string($this->scheduledTaskCallback()[0]) + ? $this->scheduledTaskCallback()[0] + : $this->scheduledTaskCallback()[0]::class, + default => $this->scheduledTaskCallback()::class, + }; + } + + /** + * Retrieve the name of the given scheduled closure task. + */ + protected function scheduledClosureTaskName(Closure $callback): string + { + $function = new ReflectionFunction($callback); + + return sprintf( + 'Closure at: %s:%s', + $this->normalizeBasePath($function->getFileName() ?: ''), + $function->getStartLine(), + ); + } + + /** + * Retrieve the class name of the currently running scheduled task. + */ + protected function scheduledTaskClass(): ?string + { + try { + if ($this->currentlyRunningScheduledTask instanceof CallbackTask) { + return $this->scheduledCallbackTaskClass(); + } + + $prefix = 'php '.preg_replace("#['\"]#", '', ConsoleApplication::artisanBinary()).' '; + + if (! str_starts_with($this->scheduledTaskName(), $prefix)) { + return null; + } + + $command = Artisan::findCommand( + explode(' ', substr($this->scheduledTaskName(), strlen($prefix)))[0] + ); + + return $command === null + ? null + : $command::class; + } catch (Throwable) { + return null; + } + } + + /** + * Retrieve the class name of the currently running scheduled callback task. + */ + protected function scheduledCallbackTaskClass(): ?string + { + return match (true) { + is_object($this->scheduledTaskCallback()) => $this->scheduledTaskCallback()::class, + is_array($this->scheduledTaskCallback()) => is_string($this->scheduledTaskCallback()[0]) + ? $this->scheduledTaskCallback()[0] + : $this->scheduledTaskCallback()[0]::class, + is_string($this->scheduledTaskCallback()) => class_exists($class = explode('@', $this->scheduledTaskCallback())[0]) + ? $class + : null, + default => null, + }; + } + + /** + * Retrieve the callback of the currently running scheduled task. + */ + protected function scheduledTaskCallback(): mixed + { + return (new ReflectionClass($this->currentlyRunningScheduledTask)) + ->getProperty('callback') + ->getValue($this->currentlyRunningScheduledTask); + } + + /** + * Retrieve the timezone of the currently running scheduled task. + */ + protected function scheduledTaskTimezone(): ?string + { + return $this->currentlyRunningScheduledTask->timezone instanceof DateTimeZone + ? $this->currentlyRunningScheduledTask->timezone->getName() + : $this->currentlyRunningScheduledTask->timezone; + } + + /** + * Prepare to process the given job. + */ + public function prepareForJob(Job $job): void + { + $this->currentlyProcessingJob = $job; + + $this->executionContext['job'] = [ + 'timestamp' => $this->timestamp(), + 'attempt' => $job->attempts(), + ]; + } + + /** + * Flush currently processing job context. + */ + public function flushJobContext(): void + { + $this->executionContext['job'] = []; + $this->currentlyProcessingJob = null; + $this->currentlyProcessingJobAttemptId = null; + } + + /** + * Determine if a queue worker is running. + */ + protected function isProcessingJob(): bool + { + return $this->currentlyProcessingJob !== null; + } + + /** + * Retrieve the currently processing job's execution context. + * + * @return array + */ + protected function jobExecutionDetails(Throwable $e): array + { + return [ + 'trace_id' => $this->traceIdFromContext(), + 'execution_type' => 'job', + 'execution_context' => [ + ...$this->executionContext['job'], + 'attempt_id' => $this->currentlyProcessingJobAttemptId(), + 'uuid' => $this->currentlyProcessingJob->uuid(), + 'name' => $this->currentlyProcessingJob->resolveName(), + 'connection' => $this->currentlyProcessingJob->getConnectionName(), + 'queue' => $this->normalizedQueue(), + ], + ]; + } + + /** + * Retrieve the trace identifier the current job belongs to. + */ + protected function traceIdFromContext(): ?string + { + try { + return Context::getHidden('laravel_cloud_trace_id'); + } catch (Throwable) { + return null; + } + } + + /** + * The currently processing job attempt ID. + */ + protected function currentlyProcessingJobAttemptId(): string + { + return $this->currentlyProcessingJobAttemptId ??= (string) Uuid::uuid7(); + } + + /** + * Normalize the given queue name. + */ + protected function normalizedQueue(): string + { + [$connection, $queue] = [ + $this->currentlyProcessingJob->getConnectionName(), + $this->currentlyProcessingJob->getQueue(), + ]; + + $key = "{$connection}:{$queue}"; + + if (isset($this->normalizedQueues[$key])) { + return $this->normalizedQueues[$key]; + } + + $this->connectionConfig ??= Config::get("queue.connections.{$connection}") ?? []; + + if (($this->connectionConfig['driver'] ?? null) === 'cloud') { + $this->connectionConfig = $this->connectionConfig['connection']; + } + + if (($this->connectionConfig['driver'] ?? null) !== 'sqs') { + return $this->normalizedQueues[$key] = $queue; + } + + if ($this->connectionConfig['prefix'] ?? null) { + $prefix = preg_quote($this->connectionConfig['prefix'], '#'); + + $queue = preg_replace("#^{$prefix}/#", '', $queue) ?? $queue; + } + + if ($this->connectionConfig['suffix'] ?? null) { + $suffix = preg_quote($this->connectionConfig['suffix'], '#'); + + $queue = preg_replace("#{$suffix}(\.fifo)?$#", '$1', $queue) ?? $queue; + } + + return $this->normalizedQueues[$key] = $queue; + } + + /** + * Remember the given user as they log out. + */ + public function rememberUser(Authenticatable $user): void + { + // The context is scoped to the current request, so the user is not remembered in later requests... + try { + Context::addHidden('laravel_cloud_user_id', $this->userIdentifier($user)); + } catch (Throwable) { + // + } + } + + /** + * Handle context dehydrating. + */ + public function handleContextDehydrating(ContextRepository $context): void + { + $this->rememberUserIdInContext($context); + $this->rememberTraceIdInContext($context); + } + + /** + * Capture the authenticated user's identifier in the given context. + */ + protected function rememberUserIdInContext(ContextRepository $context): void + { + try { + $context->addHidden('laravel_cloud_user_id', $this->userId()); + } catch (Throwable) { + // + } + } + + /** + * Capture the trace identifier of the current execution in the given context. + */ + protected function rememberTraceIdInContext(ContextRepository $context): void + { + try { + $context->addHidden('laravel_cloud_trace_id', $this->traceIdFromContext() ?? ( + App::runningInConsole() ? $this->consoleCommandTraceId() : Request::header('Cloud-Request-ID') + )); + } catch (Throwable) { + // + } + } + + /** + * Retrieve the current user ID. + */ + protected function userId(): ?string + { + try { + if (Auth::hasResolvedGuards() && Auth::hasUser()) { + return $this->userIdentifier(Auth::user()); + } + + return $this->userIdFromContext(); + } catch (Throwable) { + return null; + } + } + + /** + * Retrieve the authenticated user's identifier from the context. + */ + protected function userIdFromContext(): ?string + { + try { + return Context::getHidden('laravel_cloud_user_id'); + } catch (Throwable) { + return null; + } + } + + /** + * Retrieve the identifier of the given user. + */ + protected function userIdentifier(Authenticatable $user): ?string + { + return $user->getAuthIdentifier() === null + ? null + : (string) $user->getAuthIdentifier(); + } + + /** + * Determine if the exception was handled. + */ + protected function handled(Throwable $e): bool + { + if ($e instanceof FatalError) { + return false; + } + + foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, limit: 20) as $frame) { + if ($frame['function'] === 'report' && ! isset($frame['type'])) { + return true; + } + } + + return false; + } + + /** + * Parse the given exception. + * + * @return array + */ + protected function parseException(Throwable $e): array + { + return [ + 'class' => $e::class, + 'code' => (string) $e->getCode(), + 'message' => $e->getMessage(), + 'trace' => $this->trace($e), + ]; + } + + /** + * Parse the previous exceptions. + * + * @return list> + */ + protected function previous(Throwable $e): array + { + $previous = []; + + while ($e = $e->getPrevious()) { + $previous[] = $this->parseException($e); + } + + return $previous; + } + + /** + * Parse the stack trace for the given exception. + * + * @return list> + */ + protected function trace(Throwable $e): array + { + // Insert the exception location as the first frame... + $trace = [ + $this->normalizeFrameFileAndLine([ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]), + ]; + + foreach ($e->getTrace() as $i => $frame) { + // Skip internal frames when a PHP error has been converted to an ErrorException + if ($i < 2 && ($frame['class'] ?? null) === HandleExceptions::class) { + continue; + } + + $frame = Arr::only($frame, ['class', 'type', 'function', 'file', 'line', 'args']); + + $frame = $this->normalizeFrameFileAndLine($frame); + + if (is_array($args = $frame['args'] ?? null)) { + $frame['args'] = [ + 'types' => array_map($this->argType(...), $args, array_keys($args)), + ]; + } + + $trace[] = $frame; + } + + return $trace; + } + + /** + * Determine the type information of the given argument. + * + * @return string|array{string, string} + */ + protected function argType(mixed $argument, string|int $name): string|array + { + $type = match (gettype($argument)) { + 'NULL' => 'null', + 'string' => 'string', + 'boolean' => 'bool', + 'integer' => 'int', + 'double' => 'float', + 'array' => 'array', + 'object' => $argument::class, + 'resource' => 'resource', + 'resource (closed)' => 'resource(closed)', + default => '[unknown]', + }; + + return is_int($name) ? $type : [$name, $type]; + } + + /** + * Normalize the file and line number. + * + * @param array $frame + * @return array + */ + protected function normalizeFrameFileAndLine(array $frame): array + { + if (! is_string($frame['file'] ?? null)) { + return $frame; + } + + if ($this->reportingViewException) { + $frame = $this->mapCompiledViewFrame($frame); + + if ($frame['compiled_view'] ?? null) { + $frame['compiled_view'] = $this->normalizeBasePath($frame['compiled_view']); + } + } + + $frame['file'] = $this->normalizeBasePath($frame['file']); + + return $frame; + } + + /** + * Map compiled views to their original view file paths. + * + * @param array $frame + * @return array + */ + protected function mapCompiledViewFrame(array $frame): array + { + $normalizedView = $this->bladeMapper()->findCompiledView($frame['file']); + + if (! $normalizedView) { + return $frame; + } + + $frame['compiled_view'] = $frame['file']; + $frame['file'] = $normalizedView; + $frame['line'] = $this->bladeMapper()->detectLineNumber($normalizedView, $frame['line']); + + return $frame; + } + + /** + * Retrieve the Blade mapper. + * + * The mapper is only needed while reporting a view exception, and is + * resolved when it is first needed. + */ + protected function bladeMapper(): BladeMapper + { + return $this->bladeMapper ??= ($this->bladeMapperResolver)(); + } + + /** + * Redact the given value. + */ + protected function redactValue(string $value): string + { + $length = strlen($value); + + $bytes = $length === 1 ? 'byte' : 'bytes'; + + return "[{$length} {$bytes} redacted]"; + } + + /** + * Normalize the file path's base path. + */ + protected function normalizeBasePath(string $path): string + { + if (! str_starts_with($path, $this->basePath)) { + return $path; + } + + return str_replace('\\', '/', substr($path, strlen($this->basePath))); + } + + /** + * Retrieve the time Laravel started. + */ + protected function laravelStartedAtTimestamp(): ?string + { + try { + $microtime = match (true) { + $this->octaneRequestReceivedAt !== null => $this->octaneRequestReceivedAt, + defined('LARAVEL_START') => LARAVEL_START, + default => $_SERVER['REQUEST_TIME_FLOAT'], + }; + + return Date::createFromTimestampUTC($microtime)->toDateTimeString('microsecond'); + } catch (Throwable) { + return null; + } + } +} diff --git a/src/Illuminate/Foundation/Cloud/FailedJobProvider.php b/src/Illuminate/Foundation/Cloud/FailedJobProvider.php index aa4133da..e24fe87a 100644 --- a/src/Illuminate/Foundation/Cloud/FailedJobProvider.php +++ b/src/Illuminate/Foundation/Cloud/FailedJobProvider.php @@ -5,13 +5,18 @@ use Carbon\CarbonImmutable; use DateTimeInterface; use Illuminate\Contracts\Encryption\StringEncrypter; -use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\RequestException; +use Illuminate\Http\Client\Response; use Illuminate\Queue\Failed\CountableFailedJobProvider; use Illuminate\Queue\Failed\FailedJobProviderInterface; use Illuminate\Queue\Failed\PrunableFailedJobProvider; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Http; +use Illuminate\Support\LazyCollection; use Illuminate\Support\Str; +use Iterator; use RuntimeException; +use Throwable; class FailedJobProvider implements FailedJobProviderInterface, CountableFailedJobProvider, PrunableFailedJobProvider { @@ -36,6 +41,7 @@ public function __construct( protected FailedJobProviderInterface $failer, protected Events $events, protected StringEncrypter $encrypter, + protected ?ExceptionReporter $reporter = null, ) { // } @@ -69,7 +75,19 @@ public function log($connection, $queue, $payload, $exception) 'started_at' => $processingJobDetails['started_at']->toDateTimeString('microsecond'), 'attempts' => $processingJobDetails['attempts'], 'payload' => $payload, - 'exception' => (string) mb_convert_encoding($exception, 'UTF-8'), + 'exception_preview' => mb_substr( + string: $exception->getMessage() + ? $exception::class.': '.$exception->getMessage().' in '.$exception->getFile().':'.$exception->getLine() + : $exception::class.' in '.$exception->getFile().':'.$exception->getLine(), + start: 0, + length: 1001, + encoding: 'UTF-8', + ), + 'job_name' => (json_decode($payload, associative: true) ?? [])['displayName'] ?? '', + 'exception' => (string) $exception, + ...$this->reporter === null ? [] : [ + 'exception_id' => $this->reporter->exceptionId($exception), + ], ]); $this->queue->finishProcessingJob(timestamp: $timestamp); @@ -110,13 +128,79 @@ public function find($id) return $this->failer->find($id); } - $response = Http::connectTimeout(10) + $iterator = $this->failedJobsIterator($id); + + return new LazyCollection(static fn () => yield from $iterator); + } + + /** + * Build an iterator for the failed jobs from the given URL. + */ + protected function failedJobsIterator(string $url): Iterator + { + $payload = $this->resolveFailedJobsPayload($url); + + while ($job = array_shift($payload->data)) { + $key = $payload->links->self.':'.$job->id; + + $this->loadedFailedJobs[$key] = $job; + + yield $key => $job; + + if ($payload->data === [] && $payload->links->next !== null) { + $payload = $this->resolveFailedJobsPayload($payload->links->next); + } + } + } + + /** + * Resolve the failed jobs payload for the given URL. + * + * @return object{data: list, links: object{self: string, next: string|null}} + */ + protected function resolveFailedJobsPayload(string $url): object + { + $response = $this->fetchFailedJobs($url); + + $payload = json_decode( + json: $this->encrypter->decryptString($response->body()), + associative: false, + flags: JSON_THROW_ON_ERROR, + ); + + return match ($response->header('Cloud-Payload-Version')) { + '1' => $payload, + '' => literal( + data: [$payload], + links: literal( + self: $url, + next: null, + ), + ), + default => throw new RuntimeException('Unsupported payload version: '.$response->header('Cloud-Payload-Version')), + }; + } + + /** + * Fetch the failed jobs from the given URL. + */ + protected function fetchFailedJobs(string $url): Response + { + return Http::withoutGlobalConfiguration(fn () => Http::connectTimeout(10) ->timeout(10) - ->retry(3, 1000, fn ($exception) => $exception instanceof ConnectionException) - ->throw() - ->get($id); + ->retry(10, fn ($attempt) => 500 * (2 ** ($attempt - 1)), when: function (Throwable $e) { + if ($e instanceof RequestException && $e->response->status() === 403) { + return false; + } - return $this->loadedFailedJobs[$id] = json_decode($this->encrypter->decryptString($response->body()), flags: JSON_THROW_ON_ERROR); + return true; + }) + ->withHeaders([ + 'Cloud-Payload-Version' => '1', + 'Cloud-Encryption-Cipher' => Config::get('app.cipher'), + ]) + ->throw() + ->get($url)); } /** @@ -135,23 +219,24 @@ public function forget($id) return false; } - $this->events->emit([ + unset($this->loadedFailedJobs[$id]); + + return $this->events->emit([ '_cloud_event' => 'failed_job', 'id' => $job->id, 'queue' => $job->queue, 'retried_at' => CarbonImmutable::now('UTC')->toDateTimeString('microsecond'), ]); - - return true; } /** - * Flush all of the failed jobs from storage. + * Flush the failed jobs from storage. * * @param int|null $hours + * @param string|null $queue * @return void */ - public function flush($hours = null) + public function flush($hours = null, $queue = null) { $this->failer->flush(...func_get_args()); } diff --git a/src/Illuminate/Foundation/LaravelCloudJsonFormatter.php b/src/Illuminate/Foundation/Cloud/JsonFormatter.php similarity index 80% rename from src/Illuminate/Foundation/LaravelCloudJsonFormatter.php rename to src/Illuminate/Foundation/Cloud/JsonFormatter.php index 0501655e..c56898a9 100644 --- a/src/Illuminate/Foundation/LaravelCloudJsonFormatter.php +++ b/src/Illuminate/Foundation/Cloud/JsonFormatter.php @@ -1,12 +1,12 @@ queue->reservedSize(...func_get_args()); } + /** + * Get the number of jobs across every managed queue. + * + * @return int + */ + public function totalSize() + { + return (new Collection($this->managedQueues()))->sum(fn ($queue) => $this->size($queue)); + } + + /** + * Get the number of pending jobs across every managed queue. + * + * @return int + */ + public function totalPendingSize() + { + return (new Collection($this->managedQueues()))->sum(fn ($queue) => $this->pendingSize($queue)); + } + + /** + * Get the number of delayed jobs across every managed queue. + * + * @return int + */ + public function totalDelayedSize() + { + return (new Collection($this->managedQueues()))->sum(fn ($queue) => $this->delayedSize($queue)); + } + + /** + * Get the number of reserved jobs across every managed queue. + * + * @return int + */ + public function totalReservedSize() + { + return (new Collection($this->managedQueues()))->sum(fn ($queue) => $this->reservedSize($queue)); + } + /** * Get the creation timestamp of the oldest pending job, excluding delayed jobs. * @@ -185,6 +232,10 @@ public function bulk($jobs, $data = '', $queue = null) /** * Pop the next job off of the queue. * + * Jobs come straight from SQS unless the cloud-agent is enabled. + * + * When cloud-agent is enabled, we long-poll the agent's runtime socket instead. + * * @param string|null $queue * @return \Illuminate\Contracts\Queue\Job|null */ @@ -192,13 +243,136 @@ public function pop($queue = null) { $this->finishProcessingJob(); - $job = $this->queue->pop(...func_get_args()); + $job = $this->usesAgent($queue) + ? $this->popFromAgent() + : $this->queue->pop(...func_get_args()); $this->startProcessingJob($queue, $job); return $job; } + /** + * Long-poll the cloud-agent's runtime socket and wrap the next message in a CloudJob. + * + * @return \Illuminate\Foundation\Cloud\CloudJob|null + */ + protected function popFromAgent() + { + $data = $this->requestNextJobFromAgent(); + + if (! (is_array($data) && is_string($messageId = $data['messageId'] ?? null) && $messageId !== '')) { + return null; + } + + // Coerce a non-string handle to null so a malformed response degrades gracefully... + $receiptHandle = is_string($handle = $data['receiptHandle'] ?? null) ? $handle : null; + + return new CloudJob( + $this->queue->getContainer(), + $this->queue->getSqs(), + [ + 'MessageId' => $messageId, + 'ReceiptHandle' => $receiptHandle, + 'Body' => is_string($body = $data['body'] ?? null) ? $body : '', + 'Attributes' => $data['attributes'] ?? [], + ], + $this->queue->getConnectionName(), + $data['queueUrl'] ?? null, + fn (string $status, ?int $delay) => $this->reportJobStatusToAgent( + $messageId, $receiptHandle, $status, $delay + ), + $this->config['connection']['overflow'] ?? [], + ); + } + + /** + * Long-poll the agent's runtime socket (GET /next) for the next job. + * + * @throws \Illuminate\Foundation\Cloud\AgentUnreachableException + */ + protected function requestNextJobFromAgent(): ?array + { + try { + $response = $this->agentRequest() + ->timeout(65) + ->retry([0, 500], throw: false) + ->get('/next'); + } catch (ConnectionException $e) { + throw new AgentUnreachableException( + 'The Laravel Cloud agent runtime socket is unreachable.', previous: $e + ); + } + + if ($response->status() === 204) { + return null; + } + + if (! $response->ok()) { + throw new AgentUnreachableException( + "The Laravel Cloud agent returned HTTP {$response->status()} from GET /next." + ); + } + + if (! is_array($data = $response->json())) { + throw new AgentUnreachableException( + 'The Laravel Cloud agent returned a non-array body from GET /next.' + ); + } + + return $data; + } + + /** + * Report a job's terminal outcome back to the agent (POST /result). + * + * @throws \Illuminate\Http\Client\RequestException + * @throws \Illuminate\Foundation\Cloud\AgentUnreachableException + */ + protected function reportJobStatusToAgent(string $messageId, ?string $receiptHandle, string $status, ?int $delay = null): void + { + try { + $this->agentRequest() + ->timeout(10) + ->throw() + ->retry(3, 100, fn ($exception) => $exception instanceof ConnectionException) + ->post('/result', array_filter([ + 'messageId' => $messageId, + 'receiptHandle' => $receiptHandle, + 'status' => $status, + 'delay' => $delay, + ], fn ($value) => $value !== null)); + } catch (ConnectionException $e) { + throw new AgentUnreachableException( + 'The Laravel Cloud agent runtime socket is unreachable.', previous: $e + ); + } catch (RequestException $e) { + if ($e->response->serverError()) { + throw new AgentUnreachableException( + "The Laravel Cloud agent returned HTTP {$e->response->status()} from POST /result.", previous: $e + ); + } + + throw $e; + } + } + + /** + * Get a pending HTTP request bound to the agent's Unix runtime socket. + * + * @return \Illuminate\Http\Client\PendingRequest + */ + protected function agentRequest() + { + return Http::withoutGlobalConfiguration( + fn () => Http::baseUrl('http://localhost')->withOptions([ + 'curl' => [ + CURLOPT_UNIX_SOCKET_PATH => $this->config['agent']['socket'] ?? '/tmp/cloud-agent.sock', + ], + ]) + ); + } + /** * Delete all of the jobs from the queue. * @@ -210,6 +384,29 @@ public function clear($queue) return $this->queue->clear(...func_get_args()); } + /** + * Determine whether the next job should be received from the in-container cloud-agent. + * + * @param string|null $queue + */ + protected function usesAgent($queue = null): bool + { + return ($this->config['agent']['enabled'] ?? false) + && $this->app->runningConsoleCommand('queue:work') + && $this->queue->getQueue($queue) === $this->queue->getQueue($this->workerQueue()); + } + + /** + * Get the queue the running queue:work worker is processing. + * + * @return string + */ + protected function workerQueue() + { + return (new ArgvInput)->getParameterOption('--queue') + ?: ($this->config['queue'] ?? 'default'); + } + /** * Get the connection name for the queue. * @@ -364,12 +561,26 @@ public function normalizeQueue($queue) $prefix = $this->config['connection']['prefix'] ?? null; $suffix = $this->config['connection']['suffix'] ?? null; - return Str::of($this->queue->getQueue($queue)) + return (new Stringable($this->queue->getQueue($queue))) ->when($prefix, fn ($str) => $str->chopStart($prefix.'/')) - ->when($suffix, fn ($str) => $str->chopEnd($suffix)) + ->when($suffix, fn ($str) => $str->endsWith('.fifo') + ? $str->chopEnd('.fifo')->chopEnd($suffix)->append('.fifo') + : $str->chopEnd($suffix)) ->toString(); } + /** + * Get the names of the managed queues configured for this connection. + * + * @return array + */ + public function managedQueues() + { + $queues = $this->config['queues'] ?? []; + + return array_is_list($queues) ? $queues : array_keys($queues); + } + /** * Dynamically pass method calls to the underlying queue. * diff --git a/src/Illuminate/Foundation/Cloud/QueueConnector.php b/src/Illuminate/Foundation/Cloud/QueueConnector.php index 2ed8075d..94dc13be 100644 --- a/src/Illuminate/Foundation/Cloud/QueueConnector.php +++ b/src/Illuminate/Foundation/Cloud/QueueConnector.php @@ -5,6 +5,7 @@ use Aws\CommandInterface; use Aws\Exception\AwsException; use Aws\Sqs\SqsClient; +use Illuminate\Contracts\Database\LostConnectionDetector; use Illuminate\Foundation\Application; use Illuminate\Queue\Connectors\ConnectorInterface; use Illuminate\Queue\Events\JobQueued; @@ -39,6 +40,7 @@ public function connect(array $config): Queue $underlying = $this->connector->connect($config['connection']); $queue = new Queue( + $this->app, $underlying, $this->app[Events::class], $config, @@ -100,6 +102,20 @@ protected function configureWorker(Queue $queue): void { Worker::$restartable = false; Worker::$pausable = false; + Worker::$memoryExceededExitCode = null; + Worker::$timedOutExitCode = 124; + + Worker::killUsing(function (int $status): void { + if (function_exists('pcntl_exec')) { + @pcntl_exec('/bin/sh', ['-c', 'exit '.$status]); + } + }); + + // Exit the worker (and restart the pod) when the agent socket is unreachable... + $this->app->extend( + LostConnectionDetector::class, + fn ($detector) => new AgentAwareLostConnectionDetector($detector), + ); $this->app['events']->listen(fn (WorkerStopping $event) => match ($event->reason) { WorkerStopReason::TimedOut => $queue->finishProcessingJob(default: 'released'), diff --git a/src/Illuminate/Foundation/CloudBootstrapper.php b/src/Illuminate/Foundation/CloudBootstrapper.php new file mode 100644 index 00000000..e4eecd72 --- /dev/null +++ b/src/Illuminate/Foundation/CloudBootstrapper.php @@ -0,0 +1,444 @@ + function () use ($app) { + static::bootManagedQueues($app); + }, + default => fn () => true, + })(); + } + + /** + * Handle a bootstrapper that has bootstrapped. + */ + public static function bootstrapped(Application $app, string $bootstrapper): void + { + (match ($bootstrapper) { + LoadConfiguration::class => function () use ($app) { + static::configureDisks($app); + static::configureReadReplicaConnection($app); + static::configurePostgresConnections($app); + static::configureManagedQueues($app); + static::configureQueueCredentialCaching($app); + static::configureExceptions($app); + }, + HandleExceptions::class => function () use ($app) { + static::configureCloudLogging($app); + static::registerEvents($app); + static::registerExceptionReporting($app); + }, + default => fn () => true, + })(); + } + + /** + * Configure the Laravel Cloud disks if applicable. + */ + public static function configureDisks(Application $app): void + { + if (! isset($_SERVER['LARAVEL_CLOUD_DISK_CONFIG'])) { + return; + } + + $defaultDisk = Env::get('FILESYSTEM_DISK'); + + $disks = json_decode($_SERVER['LARAVEL_CLOUD_DISK_CONFIG'], true); + + foreach ($disks as $disk) { + if ($disk['scoped_disk'] ?? false) { + $app['config']->set('filesystems.disks.'.$disk['disk'], [ + 'driver' => 'scoped', + 'disk' => $disk['scoped_disk'], + 'prefix' => $disk['prefix'] ?? '', + ]); + } else { + $app['config']->set('filesystems.disks.'.$disk['disk'], [ + 'driver' => 's3', + 'key' => $disk['access_key_id'], + 'secret' => $disk['access_key_secret'], + 'bucket' => $disk['bucket'], + 'url' => $disk['url'], + 'endpoint' => $disk['endpoint'], + 'region' => $disk['region'] ?? 'auto', + 'credentials' => $disk['credentials'] ?? null, + 'use_path_style_endpoint' => false, + 'throw' => false, + 'report' => false, + ]); + } + + if (($disk['is_default'] ?? false) && + (blank($defaultDisk) || $defaultDisk === $disk['disk'])) { + $app['config']->set('filesystems.default', $disk['disk']); + } + } + } + + /** + * Configure the Laravel Cloud read replica if applicable. + */ + public static function configureReadReplicaConnection(Application $app): void + { + if (! isset($_SERVER['DB_READ_HOST'])) { + return; + } + + $connection = $app['config']->get('database.default'); + + $app['config']->set("database.connections.{$connection}.read.host", $_SERVER['DB_READ_HOST']); + } + + /** + * Configure Laravel Postgres connections, opting into native pooling with DB_POOLING. + */ + public static function configurePostgresConnections(Application $app): void + { + if (is_null($pooled = Env::get('DB_POOLING'))) { + static::configureUnpooledPostgresConnection($app); + static::ensureMigrationsUseUnpooledConnection($app); + + return; + } + + $connections = $app['config']->get('database.connections', []); + + foreach ($connections as $name => $config) { + if ($name === 'pgsql-unpooled') { + continue; + } + + $config = (new ConfigurationUrlParser)->parseConfiguration($config); + + $host = $config['host'] ?? null; + + if (($config['driver'] ?? null) !== 'pgsql' || + ! is_string($host) || ! str_ends_with($host, '.pg.laravel.cloud')) { + continue; + } + + [$endpoint, $domain] = explode('.', $host, 2); + + $endpoint = Str::chopEnd($endpoint, '-pooler'); + + $config['direct'] = array_replace([ + 'host' => "{$endpoint}.{$domain}", + ], $config['direct'] ?? []); + + // Keep the legacy connection and opt-out independent of the pooler... + $direct = Arr::except(array_replace($config, $config['direct']), [ + 'read', 'write', 'direct', 'pooled', 'connect_via_database', 'connect_via_port', + ]); + + $direct['options'][PDO::ATTR_EMULATE_PREPARES] = $config['direct']['options'][PDO::ATTR_EMULATE_PREPARES] ?? false; + + if ($name === 'pgsql') { + $connections['pgsql-unpooled'] ??= $direct; + } + + $config['host'] = "{$endpoint}-pooler.{$domain}"; + $config['pooled'] = true; + + $connections[$name] = $pooled ? $config : $direct + ['pooled' => false]; + } + + $app['config']->set('database.connections', $connections); + } + + /** + * Configure the unpooled Laravel Postgres connection if applicable. + */ + public static function configureUnpooledPostgresConnection(Application $app): void + { + $host = $app['config']->get('database.connections.pgsql.host', ''); + + if (str_contains($host, 'pg.laravel.cloud') && + str_contains($host, '-pooler')) { + $app['config']->set( + 'database.connections.pgsql-unpooled', + array_merge($app['config']->get('database.connections.pgsql'), [ + 'host' => str_replace('-pooler', '', $host), + ]) + ); + + $app['config']->set( + 'database.connections.pgsql.options.'.PDO::ATTR_EMULATE_PREPARES, true + ); + } + } + + /** + * Ensure that migrations use the unpooled Postgres connection if applicable. + */ + public static function ensureMigrationsUseUnpooledConnection(Application $app): void + { + if (! is_array($app['config']->get('database.connections.pgsql-unpooled'))) { + return; + } + + Migrator::resolveConnectionsUsing(function ($resolver, $connection) use ($app) { + $connection ??= $app['config']->get('database.default'); + + return $resolver->connection( + $connection === 'pgsql' ? 'pgsql-unpooled' : $connection + ); + }); + } + + /** + * Configure managed queues if applicable. + * + * @throws \JsonException + */ + public static function configureManagedQueues(Application $app): void + { + if (! isset($_SERVER['LARAVEL_CLOUD_MANAGED_QUEUES_CONFIG'])) { + return; + } + + $config = json_decode($_SERVER['LARAVEL_CLOUD_MANAGED_QUEUES_CONFIG'], associative: true, flags: JSON_THROW_ON_ERROR); + + $config['connection']['after_commit'] ??= env('CLOUD_QUEUE_AFTER_COMMIT', false); + + $config['connection']['overflow'] ??= [ + 'enabled' => env('CLOUD_QUEUE_OVERFLOW_ENABLED', false), + 'store' => env('CLOUD_QUEUE_OVERFLOW_STORE'), + 'always' => env('CLOUD_QUEUE_OVERFLOW_ALWAYS', false), + 'delete_after_processing' => env('CLOUD_QUEUE_OVERFLOW_DELETE_AFTER_PROCESSING', true), + ]; + + $config['connection']['credential_cache'] ??= [ + 'enabled' => env('CLOUD_QUEUE_CREDENTIAL_CACHE_ENABLED', false), + 'store' => env('CLOUD_QUEUE_CREDENTIAL_CACHE_STORE'), + 'fallback_store' => env('CLOUD_QUEUE_CREDENTIAL_CACHE_FALLBACK_STORE', 'file'), + ]; + + $app['config']->set('queue.connections.cloud', $config); + } + + /** + * Share cached AWS credentials across processes for all SQS queue connections. + * + * Avoids Pod Identity Agent rate limiting. + */ + public static function configureQueueCredentialCaching(Application $app): void + { + foreach ($app['config']->get('queue.connections', []) as $name => $connection) { + if (($connection['driver'] ?? null) === 'sqs' && ! isset($connection['credential_cache'])) { + $app['config']->set("queue.connections.{$name}.credential_cache", [ + 'enabled' => env('CLOUD_QUEUE_CREDENTIAL_CACHE_ENABLED', false), + 'store' => env('CLOUD_QUEUE_CREDENTIAL_CACHE_STORE'), + 'fallback_store' => env('CLOUD_QUEUE_CREDENTIAL_CACHE_FALLBACK_STORE', 'file'), + ]); + } + } + } + + /** + * Boot managed queues if applicable. + */ + public static function bootManagedQueues(Application $app): void + { + if ($app['config']->get('queue.connections.cloud.driver') !== 'cloud') { + return; + } + + $app->bind(QueueConnector::class, fn ($app) => new QueueConnector(new SqsConnector, $app)); + + $app['queue']->addConnector('cloud', $app->factory(QueueConnector::class)); + + $failer = $app['queue.failer']; + unset($app['queue.failer']); + + $app->singleton('queue.failer', fn ($app) => new FailedJobProvider( + $failer, $app[Events::class], $app['encrypter'], + $app->bound(ExceptionReporter::class) ? $app[ExceptionReporter::class] : null, + )); + } + + /** + * Configure the Laravel Cloud log channels. + */ + public static function configureCloudLogging(Application $app): void + { + $app['config']->set('logging.channels.stderr.formatter_with', [ + 'includeStacktraces' => true, + ]); + + $channel = [ + 'driver' => 'monolog', + 'level' => $_ENV['LOG_LEVEL'] ?? $_SERVER['LOG_LEVEL'] ?? 'debug', + 'handler' => SocketHandler::class, + 'formatter' => JsonFormatter::class, + 'formatter_with' => [ + 'includeStacktraces' => true, + ], + 'with' => [ + 'connectionString' => CloudBootstrapper::socket(), + 'persistent' => true, + 'timeout' => 2.0, + ], + ]; + + $app['config']->set('logging.channels.laravel-cloud-socket', $channel); + + if (! $app['config']->has('logging.channels.cloud')) { + $app['config']->set('logging.channels.cloud', $channel); + } + } + + /** + * Configure the Laravel Cloud exception reporting if applicable. + * + * @throws \JsonException + */ + public static function configureExceptions(Application $app): void + { + if (! isset($_SERVER['LARAVEL_CLOUD_EXCEPTIONS'])) { + return; + } + + $app['config']->set('_cloud.exceptions', json_decode($_SERVER['LARAVEL_CLOUD_EXCEPTIONS'], associative: true, flags: JSON_THROW_ON_ERROR)); + } + + /** + * Register the Laravel Cloud exception reporter if applicable. + */ + public static function registerExceptionReporting(Application $app): void + { + try { + if (! is_array($config = $app['config']->get('_cloud.exceptions'))) { + return; + } + + $config = [ + 'stop' => true, + 'capture_request_payload' => false, + 'redact_request_payload_fields' => ['_token', 'password', 'password_confirmation', 'current_password'], + 'redact_headers' => ['Authorization', 'Cookie', 'Proxy-Authorization', 'X-CSRF-TOKEN', 'X-XSRF-TOKEN'], + 'redact_command_input_fields' => ['token', 'password', 'key', 'secret'], + ...$config, + ]; + + $exceptionReporter = $app->instance(ExceptionReporter::class, new ExceptionReporter( + $app[Events::class], + $app->factory(BladeMapper::class), + $app->basePath().DIRECTORY_SEPARATOR, + $config, + )); + + // Defer registration of the reporter until exception handler is resolved... + $reportableRegistered = false; + + $registerReporter = function ($handler) use ($exceptionReporter, &$reportableRegistered) { + if ($reportableRegistered) { + return; + } + + try { + $handler->reportable($exceptionReporter); + + $reportableRegistered = true; + } catch (Throwable) { + // + } + }; + + $app->resolved(ExceptionHandlerContract::class) + ? $registerReporter($app[ExceptionHandlerContract::class]) + : $app->afterResolving(ExceptionHandlerContract::class, $registerReporter); + + $app['events']->listen(fn (ContextDehydrating $event) => $exceptionReporter->handleContextDehydrating($event->context)); + + if (! $app->runningInConsole()) { + $app['events']->listen(fn (RequestReceived $event) => $exceptionReporter->prepareForOctaneRequest()); + + $app['events']->listen(function (Logout $event) use ($exceptionReporter) { + if ($event->user !== null) { + $exceptionReporter->rememberUser($event->user); + } + }); + } else { + $preparedForCommand = false; + + $app['events']->listen(function (CommandStarting $event) use ($exceptionReporter, &$preparedForCommand) { + if (! $preparedForCommand) { + $exceptionReporter->prepareForCommand($event->command, $event->input); + + $preparedForCommand = true; + } + }); + + $app['events']->listen(function (JobPopped $event) use ($exceptionReporter) { + if ($event->job !== null) { + $exceptionReporter->prepareForJob($event->job); + } + }); + + $app['events']->listen(fn (Looping $event) => $exceptionReporter->flushJobContext()); + $app['events']->listen(fn (ScheduledTaskFinished $event) => $exceptionReporter->finishScheduledTask($event->task)); + $app['events']->listen(fn (ScheduledTaskSkipped $event) => $exceptionReporter->flushScheduledTaskContext()); + $app['events']->listen(fn (ScheduledTaskStarting $event) => $exceptionReporter->prepareForScheduledTask($event->task)); + $app['events']->listen(fn (WorkerStopping $event) => $exceptionReporter->flushJobContext()); + } + } catch (Throwable) { + return; + } + } + + /** + * Register the events system for Laravel Cloud. + */ + public static function registerEvents(Application $app): void + { + $app->singleton(Events::class, fn () => new Events(CloudBootstrapper::socket())); + } + + /** + * The cloud socket address. + */ + protected static function socket(): string + { + return $_ENV['LARAVEL_CLOUD_LOG_SOCKET'] ?? + $_SERVER['LARAVEL_CLOUD_LOG_SOCKET'] ?? + 'unix:///tmp/cloud-init.sock'; + } +} diff --git a/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php b/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php index d93ed8f8..79749ff1 100644 --- a/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php +++ b/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php @@ -9,17 +9,14 @@ use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Foundation\Application; use Illuminate\Foundation\Bootstrap\RegisterProviders; -use Illuminate\Foundation\Events\DiagnosingHealth; +use Illuminate\Foundation\Http\HealthCheck; use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance; use Illuminate\Foundation\Support\Providers\EventServiceProvider as AppEventServiceProvider; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as AppRouteServiceProvider; use Illuminate\Http\Middleware\PrefersJsonResponses; -use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Broadcast; -use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; -use Illuminate\Support\Facades\View; use Laravel\Folio\Folio; class ApplicationBuilder @@ -221,33 +218,7 @@ protected function buildRoutingCallback(array|string|null $web, } if (is_string($health)) { - Route::get($health, function (Request $request) { - $exception = null; - - try { - Event::dispatch(new DiagnosingHealth); - } catch (\Throwable $e) { - if (app()->hasDebugModeEnabled()) { - throw $e; - } - - report($e); - - $exception = $e->getMessage(); - } - - $status = $exception ? 500 : 200; - - if ($request->expectsJson()) { - return response()->json([ - 'status' => $exception ? 'down' : 'up', - ], $status); - } - - return response(View::file(__DIR__.'/../resources/health-up.blade.php', [ - 'exception' => $exception, - ]), status: $status); - }); + Route::get($health, HealthCheck::class); } if (is_string($web) || is_array($web)) { @@ -388,7 +359,7 @@ public function withSchedule(callable $callback) /** * Register and configure the application's exception handler. * - * @param callable(\Illuminate\Foundation\Configuration\Exceptions)|null $using + * @param (callable(\Illuminate\Foundation\Configuration\Exceptions): mixed)|null $using * @return $this */ public function withExceptions(?callable $using = null) diff --git a/src/Illuminate/Foundation/Configuration/Exceptions.php b/src/Illuminate/Foundation/Configuration/Exceptions.php index aa92d688..cc463c3d 100644 --- a/src/Illuminate/Foundation/Configuration/Exceptions.php +++ b/src/Illuminate/Foundation/Configuration/Exceptions.php @@ -150,6 +150,21 @@ public function dontReport(array|string $class) return $this; } + /** + * Indicate that the given exception type should stop job retries. + * + * @param array|string $class + * @return $this + */ + public function dontRetry(array|string $class) + { + foreach (Arr::wrap($class) as $exceptionClass) { + $this->handler->dontRetry($exceptionClass); + } + + return $this; + } + /** * Register a callback to determine if an exception should not be reported. * @@ -163,6 +178,19 @@ public function dontReportWhen(Closure $dontReportWhen) return $this; } + /** + * Register a callback to determine if an exception should stop job retries. + * + * @param (\Closure(\Throwable): bool) $dontRetryWhen + * @return $this + */ + public function dontRetryWhen(Closure $dontRetryWhen) + { + $this->handler->dontRetryWhen($dontRetryWhen); + + return $this; + } + /** * Do not report duplicate exceptions. * diff --git a/src/Illuminate/Foundation/Console/AboutCommand.php b/src/Illuminate/Foundation/Console/AboutCommand.php index da5a5a24..9eb6f132 100644 --- a/src/Illuminate/Foundation/Console/AboutCommand.php +++ b/src/Illuminate/Foundation/Console/AboutCommand.php @@ -99,7 +99,7 @@ public function handle() $this->newLine(); - return 0; + return self::SUCCESS; } /** @@ -173,7 +173,7 @@ protected function gatherApplicationInformation() 'Composer Version' => $this->composer->getVersion() ?? '-', 'Environment' => $this->laravel->environment(), 'Debug Mode' => static::format(config('app.debug'), console: $formatEnabledStatus), - 'URL' => Str::of(config('app.url'))->replace(['http://', 'https://'], ''), + 'URL' => (new Stringable(config('app.url')))->replace(['http://', 'https://'], ''), 'Maintenance Mode' => static::format($this->laravel->isDownForMaintenance(), console: $formatEnabledStatus), 'Timezone' => config('app.timezone'), 'Locale' => config('app.locale'), @@ -332,7 +332,7 @@ protected static function addToSection(string $section, $data, ?string $value = */ protected function sections() { - return (new Collection(explode(',', $this->option('only') ?? ''))) + return (new Stringable($this->option('only') ?? ''))->explode(',') ->filter() ->map(fn ($only) => $this->toSearchKeyword($only)) ->all(); diff --git a/src/Illuminate/Foundation/Console/BroadcastingInstallCommand.php b/src/Illuminate/Foundation/Console/BroadcastingInstallCommand.php index 33bc8eec..be1f1f71 100644 --- a/src/Illuminate/Foundation/Console/BroadcastingInstallCommand.php +++ b/src/Illuminate/Foundation/Console/BroadcastingInstallCommand.php @@ -5,6 +5,7 @@ use Composer\InstalledVersions; use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Arr; use Illuminate\Support\Env; use Illuminate\Support\Facades\Process; use Symfony\Component\Console\Attribute\AsCommand; @@ -12,6 +13,7 @@ use function Illuminate\Support\artisan_binary; use function Illuminate\Support\php_binary; use function Laravel\Prompts\confirm; +use function Laravel\Prompts\note; use function Laravel\Prompts\password; use function Laravel\Prompts\select; use function Laravel\Prompts\text; @@ -33,6 +35,8 @@ class BroadcastingInstallCommand extends Command {--reverb : Install Laravel Reverb as the default broadcaster} {--pusher : Install Pusher as the default broadcaster} {--ably : Install Ably as the default broadcaster} + {--mercure : Install Mercure as the default broadcaster} + {--pretend : Run dependency installation commands in dry-run mode} {--without-node : Do not prompt to install Node dependencies}'; /** @@ -49,6 +53,17 @@ class BroadcastingInstallCommand extends Command */ protected $driver = null; + /** + * The Composer packages required by each broadcasting driver. + * + * @var array> + */ + protected $driverPackages = [ + 'pusher' => ['pusher/pusher-php-server' => '*'], + 'ably' => ['ably/ably-php' => '*'], + 'mercure' => ['symfony/mercure' => '^0.8', 'web-token/jwt-library' => '^4.1'], + ]; + /** * The framework packages to install. * @@ -215,6 +230,7 @@ protected function collectDriverConfig() match ($this->driver) { 'pusher' => $this->collectPusherConfig(), 'ably' => $this->collectAblyConfig(), + 'mercure' => $this->collectMercureConfig(), default => null, }; } @@ -226,17 +242,25 @@ protected function collectDriverConfig() */ protected function installDriverPackages() { - $package = match ($this->driver) { - 'pusher' => 'pusher/pusher-php-server', - 'ably' => 'ably/ably-php', - default => null, - }; + $packages = array_filter( + $this->driverPackages[$this->driver] ?? [], + fn ($package) => ! InstalledVersions::isInstalled($package), + ARRAY_FILTER_USE_KEY, + ); - if (! $package || InstalledVersions::isInstalled($package)) { + if ($packages === []) { return; } - $this->requireComposerPackages($this->option('composer'), [$package]); + $this->requireComposerPackages( + $this->option('composer'), + array_map( + fn ($package, $constraint) => $constraint === '*' ? $package : $package.':'.$constraint, + array_keys($packages), + $packages, + ), + $this->option('pretend') + ); } /** @@ -297,6 +321,64 @@ protected function collectAblyConfig() ], $this->laravel->basePath('.env')); } + /** + * Collect the Mercure configuration. + * + * @return void + */ + protected function collectMercureConfig() + { + $variables = []; + + $hub = select('Which Mercure hub would you like to use?', [ + 'frankenphp' => "FrankenPHP's built-in hub", + 'standalone' => 'A standalone Mercure hub', + ]); + + if ($hub === 'standalone') { + $url = text( + 'Mercure Hub URL', + 'https://example.com/.well-known/mercure', + required: true, + hint: 'The URL your application publishes updates to.', + ); + + $publicUrl = text( + 'Mercure Hub Public URL', + default: $url, + required: true, + hint: 'The URL browsers subscribe to updates from.', + ); + + $variables = [ + 'MERCURE_URL' => $url, + 'MERCURE_PUBLIC_URL' => $publicUrl, + 'VITE_MERCURE_HUB_URL' => '${MERCURE_PUBLIC_URL}', + ]; + + if (parse_url($publicUrl, PHP_URL_SCHEME) === 'http') { + $variables['MERCURE_COOKIE_NAME'] = 'mercure_access_token'; + + $this->components->warn('Set "cookie_name mercure_access_token" on your Mercure hub.'); + } + } + + $variables['MERCURE_JWT_SECRET'] = password( + 'Mercure JWT Secret', + 'Leave empty to generate a random secret', + validate: fn ($value) => $value === '' || strlen($value) >= 32 + ? null + : 'The secret must be at least 32 bytes long.', + hint: 'Signs the tokens that let your app publish and browsers subscribe.', + ) ?: bin2hex(random_bytes(32)); + + if (confirm('Would you like to enable end-to-end encrypted channels ?', default: false)) { + $variables['MERCURE_ENCRYPTION_KEY'] = 'base64:'.base64_encode(random_bytes(32)); + } + + Env::writeVariables($variables, $this->laravel->basePath('.env')); + } + /** * Inject Echo configuration into the application's main file. * @@ -320,9 +402,9 @@ protected function injectFrameworkSpecificConfiguration() ]; } - $filePath = array_filter($filePaths, function ($path) { + $filePath = Arr::first($filePaths, function ($path) { return file_exists($path); - })[0] ?? null; + }); if (! $filePath) { $this->components->warn("Could not find file [{$filePaths[0]}]. Skipping automatic Echo configuration."); @@ -381,7 +463,7 @@ protected function installReverb() $this->requireComposerPackages($this->option('composer'), [ 'laravel/reverb:^1.0', - ]); + ], $this->option('pretend')); Process::run([ php_binary(), @@ -433,6 +515,12 @@ protected function installNodeDependencies() $commands[0] .= ' '.$this->frameworkPackages['react']; } + if ($this->option('pretend')) { + note(implode(' && ', $commands)); + + return; + } + $command = Process::command(implode(' && ', $commands)) ->path(base_path()); @@ -454,23 +542,18 @@ protected function installNodeDependencies() */ protected function resolveDriver(): string { - if ($this->option('reverb')) { - return 'reverb'; - } - - if ($this->option('pusher')) { - return 'pusher'; - } - - if ($this->option('ably')) { - return 'ably'; - } - - return select('Which broadcasting driver would you like to use?', [ - 'reverb' => 'Laravel Reverb', - 'pusher' => 'Pusher', - 'ably' => 'Ably', - ]); + return match (true) { + $this->option('reverb') => 'reverb', + $this->option('pusher') => 'pusher', + $this->option('ably') => 'ably', + $this->option('mercure') => 'mercure', + default => select('Which broadcasting driver would you like to use?', [ + 'reverb' => 'Laravel Reverb', + 'pusher' => 'Pusher', + 'ably' => 'Ably', + 'mercure' => 'Mercure', + ]), + }; } /** diff --git a/src/Illuminate/Foundation/Console/CastMakeCommand.php b/src/Illuminate/Foundation/Console/CastMakeCommand.php index b262b40f..99ce44d4 100644 --- a/src/Illuminate/Foundation/Console/CastMakeCommand.php +++ b/src/Illuminate/Foundation/Console/CastMakeCommand.php @@ -4,17 +4,19 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:cast')] class CastMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:cast'; + protected $signature = 'make:cast + {name : The name of the cast} + {--f|force : Create the class even if the cast already exists} + {--inbound : Generate an inbound cast class}'; /** * The console command description. @@ -65,17 +67,4 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Casts'; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the cast already exists'], - ['inbound', null, InputOption::VALUE_NONE, 'Generate an inbound cast class'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/ChannelListCommand.php b/src/Illuminate/Foundation/Console/ChannelListCommand.php index a57aa1e0..6aa09305 100644 --- a/src/Illuminate/Foundation/Console/ChannelListCommand.php +++ b/src/Illuminate/Foundation/Console/ChannelListCommand.php @@ -13,11 +13,11 @@ class ChannelListCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'channel:list'; + protected $signature = 'channel:list'; /** * The console command description. @@ -48,11 +48,11 @@ public function handle(Broadcaster $broadcaster) $this->components->warn('The [App\Providers\BroadcastServiceProvider] has not been loaded. Your private channels may not be loaded.'); } - if (! $channels->count()) { - return $this->components->error("Your application doesn't have any private broadcasting channels."); + if ($channels->isEmpty()) { + $this->components->error("Your application doesn't have any private broadcasting channels."); + } else { + $this->displayChannels($channels); } - - $this->displayChannels($channels); } /** diff --git a/src/Illuminate/Foundation/Console/ChannelMakeCommand.php b/src/Illuminate/Foundation/Console/ChannelMakeCommand.php index f5c4e555..d999a880 100644 --- a/src/Illuminate/Foundation/Console/ChannelMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ChannelMakeCommand.php @@ -4,17 +4,18 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:channel')] class ChannelMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:channel'; + protected $signature = 'make:channel + {name : The name of the channel} + {--f|force : Create the class even if the channel already exists}'; /** * The console command description. @@ -65,16 +66,4 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Broadcasting'; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the channel already exists'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/ClassMakeCommand.php b/src/Illuminate/Foundation/Console/ClassMakeCommand.php index 17144d35..e62968d0 100644 --- a/src/Illuminate/Foundation/Console/ClassMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ClassMakeCommand.php @@ -4,17 +4,19 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:class')] class ClassMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:class'; + protected $signature = 'make:class + {name : The name of the class} + {--i|invokable : Generate a single method, invokable class} + {--f|force : Create the class even if the class already exists}'; /** * The console command description. @@ -54,17 +56,4 @@ protected function resolveStubPath($stub) ? $customPath : __DIR__.$stub; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['invokable', 'i', InputOption::VALUE_NONE, 'Generate a single method, invokable class'], - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the class already exists'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/ClearCompiledCommand.php b/src/Illuminate/Foundation/Console/ClearCompiledCommand.php index d1bd30ef..f9a8fe05 100644 --- a/src/Illuminate/Foundation/Console/ClearCompiledCommand.php +++ b/src/Illuminate/Foundation/Console/ClearCompiledCommand.php @@ -9,11 +9,11 @@ class ClearCompiledCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'clear-compiled'; + protected $signature = 'clear-compiled'; /** * The console command description. diff --git a/src/Illuminate/Foundation/Console/ConfigCacheCommand.php b/src/Illuminate/Foundation/Console/ConfigCacheCommand.php index cedf0309..2eb395c6 100644 --- a/src/Illuminate/Foundation/Console/ConfigCacheCommand.php +++ b/src/Illuminate/Foundation/Console/ConfigCacheCommand.php @@ -14,11 +14,11 @@ class ConfigCacheCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'config:cache'; + protected $signature = 'config:cache'; /** * The console command description. @@ -61,7 +61,7 @@ public function handle() $configPath = $this->laravel->getCachedConfigPath(); - $this->files->put( + $this->files->replace( $configPath, 'components->error('Unrecognized configuration file.'); - return 1; + return self::FAILURE; } $this->publish($name, $config[$name], $this->laravel->configPath().'/'.$name.'.php'); diff --git a/src/Illuminate/Foundation/Console/ConfigShowCommand.php b/src/Illuminate/Foundation/Console/ConfigShowCommand.php index d3dd580e..445f7236 100644 --- a/src/Illuminate/Foundation/Console/ConfigShowCommand.php +++ b/src/Illuminate/Foundation/Console/ConfigShowCommand.php @@ -40,7 +40,7 @@ public function handle() $this->render($config); $this->newLine(); - return Command::SUCCESS; + return self::SUCCESS; } /** diff --git a/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php b/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php index 89629c5b..af9aece2 100644 --- a/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php @@ -6,8 +6,6 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:command')] class ConsoleMakeCommand extends GeneratorCommand @@ -15,11 +13,14 @@ class ConsoleMakeCommand extends GeneratorCommand use CreatesMatchingTest; /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:command'; + protected $signature = 'make:command + {name : The name of the command} + {--f|force : Create the class even if the console command already exists} + {--command= : The terminal command that will be used to invoke the class}'; /** * The console command description. @@ -75,29 +76,4 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Console\Commands'; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getArguments() - { - return [ - ['name', InputArgument::REQUIRED, 'The name of the command'], - ]; - } - - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the console command already exists'], - ['command', null, InputOption::VALUE_OPTIONAL, 'The terminal command that will be used to invoke the class'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/DevCommand.php b/src/Illuminate/Foundation/Console/DevCommand.php new file mode 100644 index 00000000..3c6c24b6 --- /dev/null +++ b/src/Illuminate/Foundation/Console/DevCommand.php @@ -0,0 +1,219 @@ +isProhibited()) { + return self::FAILURE; + } + + $devCommands = DevCommands::commands(); + + return match (PHP_OS_FAMILY) { + 'Windows' => $this->runViaConcurrently($devCommands, $packageManager), + default => $this->runViaMultiplex($devCommands, $packageManager), + }; + } + + /** + * Run the dev commands via `@laravel/multiplex`. + * + * @param DevCommandArray[] $devCommands + * @param NodePackageManager $packageManager + * @return int + */ + protected function runViaMultiplex(array $devCommands, NodePackageManager $packageManager): int + { + $multiplexCommand = $this->buildMultiplexCommand($devCommands); + + $command = $packageManager->getExecCommand($multiplexCommand); + + if (extension_loaded('pcntl')) { + pcntl_exec('/usr/bin/env', ['sh', '-c', $command]); + } + + passthru($command, $exitCode); + + return $exitCode; + } + + /** + * Build the command to run `@laravel/multiplex` with the given dev commands. + * + * @param DevCommandArray[] $devCommands + * @return string + */ + protected function buildMultiplexCommand(array $devCommands): string + { + $args = collect($devCommands) + ->map(fn ($devCommand) => implode(',', [ + $devCommand['name'].'@'.$devCommand['color'], + $devCommand['command'], + ])); + + $mode = match (true) { + $this->option('tabs') => DevCommandMode::TABS, + $this->option('stream') => DevCommandMode::STREAM, + $this->option('inline') => DevCommandMode::INLINE, + default => DevCommands::mode(), + }; + + $flags = collect([ + 'stream' => $mode === DevCommandMode::STREAM, + 'inline' => $mode === DevCommandMode::INLINE, + 'timestamps' => $this->option('timestamps') || DevCommands::shouldIncludeTimestamps(), + 'no-restart' => $this->option('no-restart') || ! DevCommands::shouldAutoRestart(), + 'json' => $this->option('json'), + ]) + ->filter() + ->keys() + ->map(fn ($flag) => "--$flag"); + + if ($bufferSize = $this->option('buffer-size') ?? DevCommands::getBufferSize()) { + $flags->push('--buffer-size='.escapeshellarg($bufferSize)); + } + + if ($streamBufferSize = $this->option('stream-buffer-size') ?? DevCommands::getStreamBufferSize()) { + $flags->push('--stream-buffer-size='.escapeshellarg($streamBufferSize)); + } + + $title = 'artisan dev · '.(config('app.name') === 'Laravel' ? basename(base_path()) : config('app.name')); + + $command = '@laravel/multiplex --title '.escapeshellarg($title); + + if (! $flags->isEmpty()) { + $command .= ' '.$flags->implode(' '); + } + + $command .= ' '.$args->map(escapeshellarg(...))->implode(' '); + + return $command; + } + + /** + * Run the dev commands via `concurrently`. + * + * @param DevCommandArray[] $devCommands + * @param NodePackageManager $packageManager + * @return int + */ + protected function runViaConcurrently(array $devCommands, NodePackageManager $packageManager): int + { + $concurrentlyCommand = $this->buildConcurrentlyCommand($devCommands); + $command = $packageManager->getExecCommand($concurrentlyCommand); + + $names = array_column($devCommands, 'name'); + $longestName = max(array_map(strlen(...), $names)); + $columns = getenv('COLUMNS'); + + putenv('COLUMNS='.max(terminal()->width() - $longestName - 4, 1)); + + foreach ($devCommands as $devCommand) { + $this->line( + sprintf( + '[%s]%s%s', + $devCommand['color'], + $devCommand['name'], + str_repeat(' ', ($longestName - strlen($devCommand['name'])) + 1), + $devCommand['command'], + ), + ); + } + + $this->line(''); + + $process = Process::forever() + ->options(['create_process_group' => true]) + ->start($command, fn ($type, $output) => $this->output->write($output)); + + sapi_windows_set_ctrl_handler($handler = fn () => $process->stop()); + + $exitCode = $process->wait()->exitCode(); + + sapi_windows_set_ctrl_handler($handler, false); + + $columns === false ? putenv('COLUMNS') : putenv("COLUMNS={$columns}"); + + return $exitCode; + } + + /** + * Build the command to run `concurrently` with the given dev commands. + * + * @param DevCommandArray[] $devCommands + * @return string + */ + protected function buildConcurrentlyCommand(array $devCommands): string + { + $names = array_column($devCommands, 'name'); + $commands = array_column($devCommands, 'command'); + $colors = array_column($devCommands, 'color'); + + $command = sprintf( + 'concurrently -c "%s" "%s" --names=%s', + implode(',', $colors), + implode('" "', $commands), + implode(',', $names), + ); + + if (DevCommands::shouldAutoRestart() && ! $this->option('no-restart')) { + $command .= ' --restart-tries=5 --restart-after=1000'; + } else { + $command .= ' --kill-others-on-fail'; + } + + if ($this->option('timestamps') || DevCommands::shouldIncludeTimestamps()) { + $command .= ' --timestamp-format="HH:mm:ss" -p "{time} [{name}]"'; + } + + return $command; + } +} diff --git a/src/Illuminate/Foundation/Console/DevListCommand.php b/src/Illuminate/Foundation/Console/DevListCommand.php new file mode 100644 index 00000000..b017b70a --- /dev/null +++ b/src/Illuminate/Foundation/Console/DevListCommand.php @@ -0,0 +1,198 @@ + array_merge($command, [ + 'source' => $this->formatSource($command['source']), + ]), $this->filterCommands(DevCommands::commands())); + + if ($this->option('json') || ! $this->input->isInteractive()) { + $this->output->writeln(json_encode($devCommands)); + + return empty($devCommands) && $this->isFiltering() ? self::FAILURE : self::SUCCESS; + } + + $this->newLine(); + + if (empty($devCommands)) { + if ($this->isFiltering()) { + $this->components->error("Your application doesn't have any dev processes matching the given criteria."); + + return self::FAILURE; + } + + $this->components->warn("Your application doesn't have any dev processes."); + + return self::SUCCESS; + } + + $names = array_column($devCommands, 'name'); + $longestName = max(array_map(strlen(...), $names)); + + $columns = Prompt::terminal()->cols(); + + foreach ($devCommands as $devCommand) { + $label = str_pad($devCommand['name'], $longestName); + $command = $devCommand['command']; + $source = ' '.$devCommand['source']; + $textWidth = mb_strwidth($label) + mb_strwidth($command) + mb_strwidth($source); + $spaceBuffer = 6; + + $dots = str_repeat('.', max(0, $columns - $textWidth - $spaceBuffer)); + + // Truncate source if it doesn't fit, but ensure command is always fully visible. + if ($textWidth + $spaceBuffer > $columns) { + $availableSourceWidth = max( + 0, + $columns - mb_strwidth($label) - mb_strwidth($command) - mb_strwidth($dots) - $spaceBuffer + ); + + $source = (new Stringable($source))->limit($availableSourceWidth - 1, '…')->value(); + } + + $this->line( + sprintf( + ' %s %s %s%s', + $devCommand['color'], + $label, + $command, + $dots, + $source, + ), + ); + } + + $countText = 'Showing ['.count($devCommands).'] dev '.(count($devCommands) === 1 ? 'command' : 'commands').' '; + + $countSpaces = max(0, $columns - mb_strwidth($countText) - 1); + $spaces = str_repeat(' ', $countSpaces); + + $this->newLine(); + $this->line($spaces.''.$countText.''); + $this->newLine(); + + return self::SUCCESS; + } + + /** + * Determine if the given command is registered by a vendor package. + * + * @param array $command + * @return bool + */ + protected function isVendorCommand(array $command): bool + { + return $command['priority'] === DevCommand::PRIORITY_VENDOR; + } + + /** + * Determine if any filtering options are active. + * + * @return bool + */ + protected function isFiltering(): bool + { + return $this->option('filter') || $this->option('except-vendor') || $this->option('only-vendor'); + } + + /** + * Filter the given commands based on the provided options. + * + * @param array $commands + * @return array + */ + protected function filterCommands(array $commands): array + { + if ($this->option('filter')) { + $commands = array_filter( + $commands, + fn ($command) => str_contains($command['name'], $this->option('filter')) || str_contains($command['command'], $this->option('filter')), + ); + } + + if ($this->option('except-vendor')) { + $commands = array_filter( + $commands, + fn ($command) => ! $this->isVendorCommand($command), + ); + } + + if ($this->option('only-vendor')) { + $commands = array_filter( + $commands, + $this->isVendorCommand(...), + ); + } + + return array_values($commands); + } + + /** + * Format the source information for display. + * + * @param array{'file': string, 'line': int, 'class'?: string, 'function'?: string} $source + * @return string + */ + protected function formatSource($source): string + { + $file = $source['file'] ?? null; + $line = $source['line'] ?? null; + $class = $source['class'] ?? null; + $function = $source['function'] ?? null; + + if ($class) { + return "{$class}@{$function}"; + } + + return implode(':', array_filter([$file, $line])); + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['json', null, InputOption::VALUE_NONE, 'Output the dev process list as JSON'], + ['filter', null, InputOption::VALUE_REQUIRED, 'Filter the dev processes by name or command'], + ['except-vendor', null, InputOption::VALUE_NONE, 'Do not display dev processes registered by vendor packages'], + ['only-vendor', null, InputOption::VALUE_NONE, 'Only display dev processes registered by vendor packages'], + ]; + } +} diff --git a/src/Illuminate/Foundation/Console/DocsCommand.php b/src/Illuminate/Foundation/Console/DocsCommand.php index 70f59f15..c1534110 100644 --- a/src/Illuminate/Foundation/Console/DocsCommand.php +++ b/src/Illuminate/Foundation/Console/DocsCommand.php @@ -117,7 +117,7 @@ public function handle(Http $http, Cache $cache) $this->refreshDocs(); - return Command::SUCCESS; + return self::SUCCESS; } /** diff --git a/src/Illuminate/Foundation/Console/DownCommand.php b/src/Illuminate/Foundation/Console/DownCommand.php index efd3bc70..b0c0b473 100644 --- a/src/Illuminate/Foundation/Console/DownCommand.php +++ b/src/Illuminate/Foundation/Console/DownCommand.php @@ -3,7 +3,6 @@ namespace Illuminate\Foundation\Console; use App\Http\Middleware\PreventRequestsDuringMaintenance as AppPreventRequestsDuringMaintenance; -use DateTimeInterface; use Exception; use Illuminate\Console\Command; use Illuminate\Foundation\Events\MaintenanceModeEnabled; @@ -74,7 +73,7 @@ public function handle() $e->getMessage(), )); - return 1; + return self::FAILURE; } } @@ -157,9 +156,7 @@ protected function getRetryTime() if (is_string($retry) && ! empty($retry)) { try { - $date = Carbon::parse($retry); - - return $date->format(DateTimeInterface::RFC7231); + return Carbon::parse($retry)->toRfc7231String(); } catch (Exception) { return null; } diff --git a/src/Illuminate/Foundation/Console/EnumMakeCommand.php b/src/Illuminate/Foundation/Console/EnumMakeCommand.php index fab08bb9..d4903314 100644 --- a/src/Illuminate/Foundation/Console/EnumMakeCommand.php +++ b/src/Illuminate/Foundation/Console/EnumMakeCommand.php @@ -5,7 +5,6 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function Laravel\Prompts\select; @@ -14,11 +13,15 @@ class EnumMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:enum'; + protected $signature = 'make:enum + {name : The name of the enum} + {--s|string : Generate a string backed enum.} + {--i|int : Generate an integer backed enum.} + {--f|force : Create the enum even if the enum already exists}'; /** * The console command description. @@ -120,18 +123,4 @@ protected function afterPromptingForMissingArguments(InputInterface $input, Outp $input->setOption($type, true); } } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['string', 's', InputOption::VALUE_NONE, 'Generate a string backed enum.'], - ['int', 'i', InputOption::VALUE_NONE, 'Generate an integer backed enum.'], - ['force', 'f', InputOption::VALUE_NONE, 'Create the enum even if the enum already exists'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/EnvironmentCommand.php b/src/Illuminate/Foundation/Console/EnvironmentCommand.php index 234d930d..e08e04bc 100644 --- a/src/Illuminate/Foundation/Console/EnvironmentCommand.php +++ b/src/Illuminate/Foundation/Console/EnvironmentCommand.php @@ -9,11 +9,11 @@ class EnvironmentCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'env'; + protected $signature = 'env'; /** * The console command description. diff --git a/src/Illuminate/Foundation/Console/EnvironmentEncryptCommand.php b/src/Illuminate/Foundation/Console/EnvironmentEncryptCommand.php index 9884eb72..10e5f825 100644 --- a/src/Illuminate/Foundation/Console/EnvironmentEncryptCommand.php +++ b/src/Illuminate/Foundation/Console/EnvironmentEncryptCommand.php @@ -25,9 +25,9 @@ class EnvironmentEncryptCommand extends Command {--key= : The encryption key} {--cipher= : The encryption cipher} {--env= : The environment to be encrypted} - {--readable : Encrypt each variable individually with readable, plain-text variable names} + {--readable : Encrypt each variable individually with readable names, updating existing files and preserving unchanged values} {--prune : Delete the original environment file} - {--force : Overwrite the existing encrypted environment file}'; + {--force : Re-encrypt all values, overwriting the existing encrypted environment file}'; /** * The console command description. @@ -64,9 +64,25 @@ public function handle() { $cipher = $this->option('cipher') ?: 'AES-256-CBC'; + $environmentFile = $this->option('env') + ? Str::finish($this->laravel->environmentPath(), DIRECTORY_SEPARATOR).'.env.'.$this->option('env') + : $this->laravel->environmentFilePath(); + + $encryptedFile = $environmentFile.'.encrypted'; + + if (! $this->files->exists($environmentFile)) { + $this->fail('Environment file not found.'); + } + + $encryptedFileExists = $this->files->exists($encryptedFile); + + $preserve = $this->option('readable') && $encryptedFileExists && ! $this->option('force'); + $key = $this->option('key'); - if (! $key && $this->input->isInteractive()) { + if (! $key && $this->input->isInteractive() && $preserve) { + $key = password('What is the encryption key?'); + } elseif (! $key && $this->input->isInteractive()) { $ask = select( label: 'What encryption key would you like to use?', options: [ @@ -76,28 +92,22 @@ public function handle() default: 'generate' ); - if ($ask == 'ask') { + if ($ask === 'ask') { $key = password('What is the encryption key?'); } } - $keyPassed = $key !== null; - - $environmentFile = $this->option('env') - ? Str::finish($this->laravel->environmentPath(), DIRECTORY_SEPARATOR).'.env.'.$this->option('env') - : $this->laravel->environmentFilePath(); + if ($preserve && ($key === null || $key === '')) { + $this->fail('The existing encryption key is required to update the encrypted environment file.'); + } - $encryptedFile = $environmentFile.'.encrypted'; + $keyPassed = $key !== null; if (! $keyPassed) { $key = Encrypter::generateKey($cipher); } - if (! $this->files->exists($environmentFile)) { - $this->fail('Environment file not found.'); - } - - if ($this->files->exists($encryptedFile) && ! $this->option('force')) { + if ($encryptedFileExists && ! $this->option('force') && ! $preserve) { $this->fail('Encrypted environment file already exists.'); } @@ -106,11 +116,21 @@ public function handle() $contents = $this->files->get($environmentFile); + $previous = $preserve + ? $this->files->get($encryptedFile) + : null; + $encrypted = $this->option('readable') - ? $this->encryptReadableFormat($contents, $encrypter) + ? $this->encryptWhileMaintainingReadability($contents, $encrypter, $previous) : $encrypter->encrypt($contents); - $this->files->put($encryptedFile, $encrypted); + if ($encrypted !== $previous) { + $written = $this->files->put($encryptedFile, $encrypted); + + if ($written === false) { + $this->fail('Unable to write the encrypted environment file.'); + } + } } catch (Exception $e) { $this->fail($e->getMessage()); } @@ -133,11 +153,19 @@ public function handle() * * @param string $contents * @param \Illuminate\Encryption\Encrypter $encrypter + * @param string|null $previous * @return string */ - protected function encryptReadableFormat(string $contents, Encrypter $encrypter): string + protected function encryptWhileMaintainingReadability(string $contents, Encrypter $encrypter, ?string $previous = null): string { $result = ''; + $existing = []; + $previousOutput = ''; + + foreach ($previous === null ? [] : $this->readEncryptedEntries($previous, $encrypter) as $entry) { + $existing[$entry['name']][] = $entry; + $previousOutput .= $entry['name'].'='.$entry['encrypted']."\n"; + } foreach (Lines::process(preg_split('/\r\n|\r|\n/', $contents)) as $entry) { $pos = strpos($entry, '='); @@ -149,10 +177,65 @@ protected function encryptReadableFormat(string $contents, Encrypter $encrypter) $name = substr($entry, 0, $pos); $value = substr($entry, $pos + 1); - $result .= $name.'='.$encrypter->encryptString($value)."\n"; + $existingEntry = null; + + foreach ($existing[$name] ?? [] as $index => $candidate) { + if ($candidate['value'] === $value) { + $existingEntry = $candidate; + unset($existing[$name][$index]); + + break; + } + } + + $result .= $name.'='.($existingEntry !== null + ? $existingEntry['encrypted'] + : $encrypter->encryptString($value))."\n"; + } + + return $previous !== null && $result === $previousOutput ? $previous : $result; + } + + /** + * Authenticate the existing readable entries, preserving order and duplicate names. + * + * @param string $contents + * @param \Illuminate\Encryption\Encrypter $encrypter + * @return array + */ + protected function readEncryptedEntries(string $contents, Encrypter $encrypter): array + { + if (Encrypter::appearsEncrypted($contents)) { + $this->fail('The existing encrypted environment file is not in readable format. Use --force to overwrite it.'); + } + + $entries = []; + + foreach (preg_split('/\r\n|\r|\n/', $contents) as $index => $line) { + if (trim($line) === '' || str_starts_with(ltrim($line), '#')) { + continue; + } + + $pos = strpos($line, '='); + + if ($pos === false || $pos === 0) { + $this->fail('Invalid encrypted environment entry on line '.($index + 1).'.'); + } + + $encrypted = substr($line, $pos + 1); + + try { + $value = $encrypter->decryptString($encrypted); + } catch (Exception $e) { + $this->fail('Unable to decrypt the encrypted environment entry on line '.($index + 1).'.'); + } + + $name = substr($line, 0, $pos); + + $entries[] = compact('name', 'value', 'encrypted'); } - return $result; + return $entries; } /** diff --git a/src/Illuminate/Foundation/Console/EventCacheCommand.php b/src/Illuminate/Foundation/Console/EventCacheCommand.php index 4b6edf67..96ad3310 100644 --- a/src/Illuminate/Foundation/Console/EventCacheCommand.php +++ b/src/Illuminate/Foundation/Console/EventCacheCommand.php @@ -32,7 +32,7 @@ public function handle() { $this->callSilent('event:clear'); - file_put_contents( + $this->laravel->make('files')->replace( $this->laravel->getCachedEventsPath(), 'getEvents(), true).';' ); diff --git a/src/Illuminate/Foundation/Console/EventClearCommand.php b/src/Illuminate/Foundation/Console/EventClearCommand.php index 61c50330..d2b161ec 100644 --- a/src/Illuminate/Foundation/Console/EventClearCommand.php +++ b/src/Illuminate/Foundation/Console/EventClearCommand.php @@ -10,11 +10,11 @@ class EventClearCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'event:clear'; + protected $signature = 'event:clear'; /** * The console command description. diff --git a/src/Illuminate/Foundation/Console/EventGenerateCommand.php b/src/Illuminate/Foundation/Console/EventGenerateCommand.php index ad99e937..72cd8bfb 100644 --- a/src/Illuminate/Foundation/Console/EventGenerateCommand.php +++ b/src/Illuminate/Foundation/Console/EventGenerateCommand.php @@ -10,11 +10,11 @@ class EventGenerateCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'event:generate'; + protected $signature = 'event:generate'; /** * The console command description. diff --git a/src/Illuminate/Foundation/Console/EventMakeCommand.php b/src/Illuminate/Foundation/Console/EventMakeCommand.php index 515c6fbd..44c0244f 100644 --- a/src/Illuminate/Foundation/Console/EventMakeCommand.php +++ b/src/Illuminate/Foundation/Console/EventMakeCommand.php @@ -4,17 +4,18 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:event')] class EventMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:event'; + protected $signature = 'make:event + {name : The name of the event} + {--f|force : Create the class even if the event already exists}'; /** * The console command description. @@ -75,16 +76,4 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Events'; } - - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the event already exists'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php b/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php index a38b11c7..02f5b9e4 100644 --- a/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php @@ -5,7 +5,6 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function Laravel\Prompts\confirm; @@ -14,11 +13,15 @@ class ExceptionMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:exception'; + protected $signature = 'make:exception + {name : The name of the exception} + {--f|force : Create the class even if the exception already exists} + {--render : Create the exception with an empty render method} + {--report : Create the exception with an empty report method}'; /** * The console command description. @@ -90,18 +93,4 @@ protected function afterPromptingForMissingArguments(InputInterface $input, Outp $input->setOption('report', confirm('Should the exception have a report method?', default: false)); $input->setOption('render', confirm('Should the exception have a render method?', default: false)); } - - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the exception already exists'], - ['render', null, InputOption::VALUE_NONE, 'Create the exception with an empty render method'], - ['report', null, InputOption::VALUE_NONE, 'Create the exception with an empty report method'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/InteractsWithComposerPackages.php b/src/Illuminate/Foundation/Console/InteractsWithComposerPackages.php index 4628f294..f01041ea 100644 --- a/src/Illuminate/Foundation/Console/InteractsWithComposerPackages.php +++ b/src/Illuminate/Foundation/Console/InteractsWithComposerPackages.php @@ -13,9 +13,10 @@ trait InteractsWithComposerPackages * * @param string $composer * @param array $packages + * @param bool $pretend * @return bool */ - protected function requireComposerPackages(string $composer, array $packages) + protected function requireComposerPackages(string $composer, array $packages, bool $pretend = false) { if ($composer !== 'global') { $command = [$this->phpBinary(), $composer, 'require']; @@ -23,9 +24,14 @@ protected function requireComposerPackages(string $composer, array $packages) $command = array_merge( $command ?? ['composer', 'require'], + ['--with-all-dependencies'], $packages, ); + if ($pretend) { + $command[] = '--dry-run'; + } + return ! (new Process($command, $this->laravel->basePath(), ['COMPOSER_MEMORY_LIMIT' => '-1'])) ->setTimeout(null) ->run(function ($type, $output) { diff --git a/src/Illuminate/Foundation/Console/InterfaceMakeCommand.php b/src/Illuminate/Foundation/Console/InterfaceMakeCommand.php index 6601be63..8d7858da 100644 --- a/src/Illuminate/Foundation/Console/InterfaceMakeCommand.php +++ b/src/Illuminate/Foundation/Console/InterfaceMakeCommand.php @@ -4,17 +4,18 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:interface')] class InterfaceMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:interface'; + protected $signature = 'make:interface + {name : The name of the interface} + {--f|force : Create the interface even if the interface already exists}'; /** * The console command description. @@ -54,16 +55,4 @@ protected function getDefaultNamespace($rootNamespace) default => $rootNamespace, }; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the interface even if the interface already exists'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/JobMakeCommand.php b/src/Illuminate/Foundation/Console/JobMakeCommand.php index 43d2f161..d3de12fa 100644 --- a/src/Illuminate/Foundation/Console/JobMakeCommand.php +++ b/src/Illuminate/Foundation/Console/JobMakeCommand.php @@ -5,7 +5,6 @@ use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:job')] class JobMakeCommand extends GeneratorCommand @@ -13,11 +12,15 @@ class JobMakeCommand extends GeneratorCommand use CreatesMatchingTest; /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:job'; + protected $signature = 'make:job + {name : The name of the job} + {--f|force : Create the class even if the job already exists} + {--sync : Indicates that the job should be synchronous} + {--batched : Indicates that the job should be batchable}'; /** * The console command description. @@ -72,18 +75,4 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Jobs'; } - - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the job already exists'], - ['sync', null, InputOption::VALUE_NONE, 'Indicates that the job should be synchronous'], - ['batched', null, InputOption::VALUE_NONE, 'Indicates that the job should be batchable'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/JobMiddlewareMakeCommand.php b/src/Illuminate/Foundation/Console/JobMiddlewareMakeCommand.php index 5f94b835..c2881da4 100644 --- a/src/Illuminate/Foundation/Console/JobMiddlewareMakeCommand.php +++ b/src/Illuminate/Foundation/Console/JobMiddlewareMakeCommand.php @@ -5,7 +5,6 @@ use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:job-middleware')] class JobMiddlewareMakeCommand extends GeneratorCommand @@ -13,11 +12,13 @@ class JobMiddlewareMakeCommand extends GeneratorCommand use CreatesMatchingTest; /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:job-middleware'; + protected $signature = 'make:job-middleware + {name : The name of the middleware} + {--f|force : Create the class even if the job middleware already exists}'; /** * The console command description. @@ -66,16 +67,4 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Jobs\Middleware'; } - - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the job middleware already exists'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/Kernel.php b/src/Illuminate/Foundation/Console/Kernel.php index b201c531..976cbad7 100644 --- a/src/Illuminate/Foundation/Console/Kernel.php +++ b/src/Illuminate/Foundation/Console/Kernel.php @@ -98,6 +98,13 @@ class Kernel implements KernelContract */ protected $loadedPaths = []; + /** + * The commands registered through the "load" and "command" methods. + * + * @var array + */ + protected $kernelCommands = []; + /** * All of the registered command duration handlers. * @@ -336,9 +343,7 @@ public function command($signature, Closure $callback) { $command = new ClosureCommand($signature, $callback); - Artisan::starting(function ($artisan) use ($command) { - $artisan->add($command); - }); + $this->kernelCommands[] = $command; return $command; } @@ -382,9 +387,7 @@ protected function load($paths) }; foreach ($this->findCommands($paths)->filter($filterCommands) as $file) { - Artisan::starting(function ($artisan) use ($file, $possibleCommands) { - $artisan->resolve($possibleCommands[$file]); - }); + $this->kernelCommands[] = $possibleCommands[$file]; } } @@ -459,6 +462,19 @@ public function queue($command, array $parameters = []) return QueuedCommand::dispatch(func_get_args()); } + /** + * Get the registered command instance with the given name, if any. + * + * @param string $name + * @return \Symfony\Component\Console\Command\Command|null + */ + public function findCommand($name) + { + $artisan = $this->getArtisan(); + + return $artisan->has($name) ? $artisan->get($name) : null; + } + /** * Get all of the commands registered with the console. * @@ -558,7 +574,7 @@ protected function getArtisan() { if (is_null($this->artisan)) { $this->artisan = (new Artisan($this->app, $this->events, $this->app->version())) - ->resolveCommands($this->commands) + ->resolveCommands([...$this->kernelCommands, ...$this->commands]) ->setContainerCommandLoader(); if ($this->symfonyDispatcher instanceof EventDispatcher) { diff --git a/src/Illuminate/Foundation/Console/ListenerMakeCommand.php b/src/Illuminate/Foundation/Console/ListenerMakeCommand.php index a2d2dfaa..718c27aa 100644 --- a/src/Illuminate/Foundation/Console/ListenerMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ListenerMakeCommand.php @@ -7,7 +7,6 @@ use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function Laravel\Prompts\suggest; @@ -18,11 +17,15 @@ class ListenerMakeCommand extends GeneratorCommand use CreatesMatchingTest; /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:listener'; + protected $signature = 'make:listener + {name : The name of the listener} + {--e|event= : The event class being listened for} + {--f|force : Create the class even if the listener already exists} + {--queued : Indicates the event listener should be queued}'; /** * The console command description. @@ -118,20 +121,6 @@ protected function getDefaultNamespace($rootNamespace) return $rootNamespace.'\Listeners'; } - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['event', 'e', InputOption::VALUE_OPTIONAL, 'The event class being listened for'], - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the listener already exists'], - ['queued', null, InputOption::VALUE_NONE, 'Indicates the event listener should be queued'], - ]; - } - /** * Interact further with the user if they were prompted for missing arguments. * diff --git a/src/Illuminate/Foundation/Console/MailMakeCommand.php b/src/Illuminate/Foundation/Console/MailMakeCommand.php index cc5bb337..5f2aa921 100644 --- a/src/Illuminate/Foundation/Console/MailMakeCommand.php +++ b/src/Illuminate/Foundation/Console/MailMakeCommand.php @@ -5,11 +5,10 @@ use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; use Illuminate\Foundation\Inspiring; -use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function Laravel\Prompts\select; @@ -20,11 +19,15 @@ class MailMakeCommand extends GeneratorCommand use CreatesMatchingTest; /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:mail'; + protected $signature = 'make:mail + {name : The name of the mailable} + {--f|force : Create the class even if the mailable already exists} + {--m|markdown= : Create a new Markdown template for the mailable} + {--view= : Create a new Blade template for the mailable}'; /** * The console command description. @@ -40,6 +43,16 @@ class MailMakeCommand extends GeneratorCommand */ protected $type = 'Mailable'; + #[\Override] + protected function configureDefaults(): void + { + // These default to false (not null) so that handle() can distinguish "not + // passed at all" from "passed with no value", which can't be expressed as a + // literal boolean default in the signature above. + $this->getDefinition()->getOption('markdown')->setDefault(false); + $this->getDefinition()->getOption('view')->setDefault(false); + } + /** * Execute the console command. * @@ -155,7 +168,7 @@ protected function getView() if (! $view) { $name = str_replace('\\', '/', $this->argument('name')); - $view = 'mail.'.(new Collection(explode('/', $name))) + $view = 'mail.'.(new Stringable($name))->explode('/') ->map(fn ($part) => Str::kebab($part)) ->implode('.'); } @@ -205,20 +218,6 @@ protected function getDefaultNamespace($rootNamespace) return $rootNamespace.'\Mail'; } - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the mailable already exists'], - ['markdown', 'm', InputOption::VALUE_OPTIONAL, 'Create a new Markdown template for the mailable', false], - ['view', null, InputOption::VALUE_OPTIONAL, 'Create a new Blade template for the mailable', false], - ]; - } - /** * Interact further with the user if they were prompted for missing arguments. * diff --git a/src/Illuminate/Foundation/Console/ModelMakeCommand.php b/src/Illuminate/Foundation/Console/ModelMakeCommand.php index c63dd32e..8f2e71fd 100644 --- a/src/Illuminate/Foundation/Console/ModelMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ModelMakeCommand.php @@ -6,9 +6,9 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function Laravel\Prompts\confirm; @@ -20,11 +20,24 @@ class ModelMakeCommand extends GeneratorCommand use CreatesMatchingTest; /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:model'; + protected $signature = 'make:model + {name : The name of the model} + {--a|all : Generate a migration, seeder, factory, policy, resource controller, and form request classes for the model} + {--c|controller : Create a new controller for the model} + {--f|factory : Create a new factory for the model} + {--force : Create the class even if the model already exists} + {--m|migration : Create a new migration file for the model} + {--morph-pivot : Indicates if the generated model should be a custom polymorphic intermediate table model} + {--policy : Create a new policy for the model} + {--s|seed : Create a new seeder for the model} + {--p|pivot : Indicates if the generated model should be a custom intermediate table model} + {--r|resource : Indicates if the generated controller should be a resource controller} + {--api : Indicates if the generated controller should be an API resource controller} + {--R|requests : Create new form request classes and use them in the resource controller}'; /** * The console command description. @@ -43,7 +56,7 @@ class ModelMakeCommand extends GeneratorCommand /** * Execute the console command. * - * @return void + * @return bool|null */ public function handle() { @@ -262,7 +275,7 @@ protected function buildFactoryReplacements() $replacements = []; if ($this->option('factory') || $this->option('all')) { - $modelPath = Str::of($this->argument('name'))->studly()->replace('/', '\\')->toString(); + $modelPath = (new Stringable($this->argument('name')))->studly()->replace('/', '\\')->toString(); $factoryNamespace = '\\Database\\Factories\\'.$modelPath.'Factory'; @@ -282,29 +295,6 @@ protected function buildFactoryReplacements() return $replacements; } - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['all', 'a', InputOption::VALUE_NONE, 'Generate a migration, seeder, factory, policy, resource controller, and form request classes for the model'], - ['controller', 'c', InputOption::VALUE_NONE, 'Create a new controller for the model'], - ['factory', 'f', InputOption::VALUE_NONE, 'Create a new factory for the model'], - ['force', null, InputOption::VALUE_NONE, 'Create the class even if the model already exists'], - ['migration', 'm', InputOption::VALUE_NONE, 'Create a new migration file for the model'], - ['morph-pivot', null, InputOption::VALUE_NONE, 'Indicates if the generated model should be a custom polymorphic intermediate table model'], - ['policy', null, InputOption::VALUE_NONE, 'Create a new policy for the model'], - ['seed', 's', InputOption::VALUE_NONE, 'Create a new seeder for the model'], - ['pivot', 'p', InputOption::VALUE_NONE, 'Indicates if the generated model should be a custom intermediate table model'], - ['resource', 'r', InputOption::VALUE_NONE, 'Indicates if the generated controller should be a resource controller'], - ['api', null, InputOption::VALUE_NONE, 'Indicates if the generated controller should be an API resource controller'], - ['requests', 'R', InputOption::VALUE_NONE, 'Create new form request classes and use them in the resource controller'], - ]; - } - /** * Interact further with the user if they were prompted for missing arguments. * diff --git a/src/Illuminate/Foundation/Console/NotificationMakeCommand.php b/src/Illuminate/Foundation/Console/NotificationMakeCommand.php index 44d16438..7752b61b 100644 --- a/src/Illuminate/Foundation/Console/NotificationMakeCommand.php +++ b/src/Illuminate/Foundation/Console/NotificationMakeCommand.php @@ -4,11 +4,10 @@ use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; -use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function Laravel\Prompts\confirm; @@ -20,11 +19,14 @@ class NotificationMakeCommand extends GeneratorCommand use CreatesMatchingTest; /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:notification'; + protected $signature = 'make:notification + {name : The name of the notification} + {--f|force : Create the class even if the notification already exists} + {--m|markdown= : Create a new Markdown template for the notification}'; /** * The console command description. @@ -40,6 +42,13 @@ class NotificationMakeCommand extends GeneratorCommand */ protected $type = 'Notification'; + #[\Override] + protected function configureDefaults(): void + { + // Default to false to distinguish "not passed" from "passed with no value"... + $this->getDefinition()->getOption('markdown')->setDefault(false); + } + /** * Execute the console command. * @@ -51,7 +60,7 @@ public function handle() return; } - if ($this->option('markdown')) { + if ($this->option('markdown') !== false) { $this->writeMarkdownTemplate(); } } @@ -70,7 +79,7 @@ protected function writeMarkdownTemplate() } $path = $this->viewPath( - str_replace('.', $separator, $this->option('markdown')).'.blade.php' + str_replace('.', $separator, $this->getView()).'.blade.php' ); if (! $this->files->isDirectory(dirname($path))) { @@ -92,8 +101,8 @@ protected function buildClass($name) { $class = parent::buildClass($name); - if ($this->option('markdown')) { - $class = str_replace(['DummyView', '{{ view }}'], $this->option('markdown'), $class); + if ($this->option('markdown') !== false) { + $class = str_replace(['DummyView', '{{ view }}'], $this->getView(), $class); } return $class; @@ -106,7 +115,7 @@ protected function buildClass($name) */ protected function getStub() { - return $this->option('markdown') + return $this->option('markdown') !== false ? $this->resolveStubPath('/stubs/markdown-notification.stub') : $this->resolveStubPath('/stubs/notification.stub'); } @@ -151,27 +160,26 @@ protected function afterPromptingForMissingArguments(InputInterface $input, Outp $wantsMarkdownView = confirm('Would you like to create a markdown view?'); if ($wantsMarkdownView) { - $defaultMarkdownView = (new Collection(explode('/', str_replace('\\', '/', $this->argument('name'))))) - ->map(fn ($path) => Str::kebab($path)) - ->prepend('mail') - ->implode('.'); - - $markdownView = text('What should the markdown view be named?', default: $defaultMarkdownView); + $markdownView = text('What should the markdown view be named?', default: $this->getView()); $input->setOption('markdown', $markdownView); } } /** - * Get the console command options. + * Get the view name. * - * @return array + * @return string */ - protected function getOptions() + protected function getView() { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the notification already exists'], - ['markdown', 'm', InputOption::VALUE_OPTIONAL, 'Create a new Markdown template for the notification'], - ]; + if ($view = $this->option('markdown')) { + return $view; + } + + return (new Stringable($this->argument('name')))->replace('\\', '/')->explode('/') + ->map(fn ($path) => Str::kebab($path)) + ->prepend('mail') + ->implode('.'); } } diff --git a/src/Illuminate/Foundation/Console/ObserverMakeCommand.php b/src/Illuminate/Foundation/Console/ObserverMakeCommand.php index 61f3fceb..20d8eaa9 100644 --- a/src/Illuminate/Foundation/Console/ObserverMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ObserverMakeCommand.php @@ -6,7 +6,6 @@ use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function Laravel\Prompts\suggest; @@ -15,11 +14,14 @@ class ObserverMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:observer'; + protected $signature = 'make:observer + {name : The name of the observer} + {--f|force : Create the class even if the observer already exists} + {--m|model= : The model that the observer applies to}'; /** * The console command description. @@ -131,19 +133,6 @@ protected function getDefaultNamespace($rootNamespace) return $rootNamespace.'\Observers'; } - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the observer already exists'], - ['model', 'm', InputOption::VALUE_OPTIONAL, 'The model that the observer applies to'], - ]; - } - /** * Interact further with the user if they were prompted for missing arguments. * diff --git a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php index 414a0d57..8f502566 100644 --- a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php @@ -5,18 +5,18 @@ use Illuminate\Console\Command; use Illuminate\Support\Collection; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'optimize:clear')] class OptimizeClearCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'optimize:clear'; + protected $signature = 'optimize:clear {--e|except= : The commands to skip}'; /** * The console command description. @@ -34,7 +34,7 @@ public function handle() { $this->components->info('Clearing cached bootstrap files.'); - $exceptions = Collection::wrap(explode(',', $this->option('except') ?? '')) + $exceptions = (new Stringable($this->option('except') ?? ''))->explode(',') ->map(fn ($except) => trim($except)) ->filter() ->unique() @@ -45,7 +45,7 @@ public function handle() ->toArray(); foreach ($tasks as $description => $command) { - $this->components->task($description, fn () => $this->callSilently($command) == 0); + $this->components->task($description, fn () => $this->callSilently($command) === 0); } $this->newLine(); @@ -68,16 +68,4 @@ public function getOptimizeClearTasks() ...ServiceProvider::$optimizeClearCommands, ]; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['except', 'e', InputOption::VALUE_OPTIONAL, 'The commands to skip'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/OptimizeCommand.php b/src/Illuminate/Foundation/Console/OptimizeCommand.php index ef78e8c0..71b9c4a1 100644 --- a/src/Illuminate/Foundation/Console/OptimizeCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeCommand.php @@ -5,18 +5,18 @@ use Illuminate\Console\Command; use Illuminate\Support\Collection; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'optimize')] class OptimizeCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'optimize'; + protected $signature = 'optimize {--e|except= : Do not run the commands matching the key or name}'; /** * The console command description. @@ -34,7 +34,7 @@ public function handle() { $this->components->info('Caching framework bootstrap, configuration, and metadata.'); - $exceptions = Collection::wrap(explode(',', $this->option('except') ?? '')) + $exceptions = (new Stringable($this->option('except') ?? ''))->explode(',') ->map(fn ($except) => trim($except)) ->filter() ->unique() @@ -45,7 +45,7 @@ public function handle() ->toArray(); foreach ($tasks as $description => $command) { - $this->components->task($description, fn () => $this->callSilently($command) == 0); + $this->components->task($description, fn () => $this->callSilently($command) === 0); } $this->newLine(); @@ -66,16 +66,4 @@ protected function getOptimizeTasks() ...ServiceProvider::$optimizeCommands, ]; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['except', 'e', InputOption::VALUE_OPTIONAL, 'Do not run the commands matching the key or name'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/PolicyMakeCommand.php b/src/Illuminate/Foundation/Console/PolicyMakeCommand.php index 96caa392..03c4a0bf 100644 --- a/src/Illuminate/Foundation/Console/PolicyMakeCommand.php +++ b/src/Illuminate/Foundation/Console/PolicyMakeCommand.php @@ -7,7 +7,6 @@ use LogicException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function Laravel\Prompts\suggest; @@ -16,11 +15,15 @@ class PolicyMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:policy'; + protected $signature = 'make:policy + {name : The name of the policy} + {--f|force : Create the class even if the policy already exists} + {--m|model= : The model that the policy applies to} + {--g|guard= : The guard that the policy relies on}'; /** * The console command description. @@ -189,20 +192,6 @@ protected function getDefaultNamespace($rootNamespace) return $rootNamespace.'\Policies'; } - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the policy already exists'], - ['model', 'm', InputOption::VALUE_OPTIONAL, 'The model that the policy applies to'], - ['guard', 'g', InputOption::VALUE_OPTIONAL, 'The guard that the policy relies on'], - ]; - } - /** * Interact further with the user if they were prompted for missing arguments. * diff --git a/src/Illuminate/Foundation/Console/ProviderMakeCommand.php b/src/Illuminate/Foundation/Console/ProviderMakeCommand.php index d955c825..ecba259b 100644 --- a/src/Illuminate/Foundation/Console/ProviderMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ProviderMakeCommand.php @@ -5,17 +5,18 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\ServiceProvider; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:provider')] class ProviderMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:provider'; + protected $signature = 'make:provider + {name : The name of the provider} + {--f|force : Create the class even if the provider already exists}'; /** * The console command description. @@ -87,16 +88,4 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Providers'; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the provider already exists'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/ReloadCommand.php b/src/Illuminate/Foundation/Console/ReloadCommand.php index e5467d30..2ab6fc04 100644 --- a/src/Illuminate/Foundation/Console/ReloadCommand.php +++ b/src/Illuminate/Foundation/Console/ReloadCommand.php @@ -5,18 +5,18 @@ use Illuminate\Console\Command; use Illuminate\Support\Collection; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'reload')] class ReloadCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'reload'; + protected $signature = 'reload {--e|except= : The commands to skip}'; /** * The console command description. @@ -34,7 +34,7 @@ public function handle() { $this->components->info('Reloading services.'); - $exceptions = Collection::wrap(explode(',', $this->option('except') ?? '')) + $exceptions = (new Stringable($this->option('except') ?? ''))->explode(',') ->map(fn ($except) => trim($except)) ->filter() ->unique() @@ -45,7 +45,7 @@ public function handle() ->toArray(); foreach ($tasks as $description => $command) { - $this->components->task($description, fn () => $this->callSilently($command) == 0); + $this->components->task($description, fn () => $this->callSilently($command) === 0); } $this->newLine(); @@ -64,16 +64,4 @@ public function getReloadTasks() ...ServiceProvider::$reloadCommands, ]; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['except', 'e', InputOption::VALUE_OPTIONAL, 'The commands to skip'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/RequestMakeCommand.php b/src/Illuminate/Foundation/Console/RequestMakeCommand.php index a60b0c69..d9bbea54 100644 --- a/src/Illuminate/Foundation/Console/RequestMakeCommand.php +++ b/src/Illuminate/Foundation/Console/RequestMakeCommand.php @@ -4,17 +4,18 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:request')] class RequestMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:request'; + protected $signature = 'make:request + {name : The name of the request} + {--f|force : Create the class even if the request already exists}'; /** * The console command description. @@ -63,16 +64,4 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Http\Requests'; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the request already exists'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/ResourceMakeCommand.php b/src/Illuminate/Foundation/Console/ResourceMakeCommand.php index 9b57b818..93802bff 100644 --- a/src/Illuminate/Foundation/Console/ResourceMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ResourceMakeCommand.php @@ -4,17 +4,20 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:resource')] class ResourceMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:resource'; + protected $signature = 'make:resource + {name : The name of the resource} + {--f|force : Create the class even if the resource already exists} + {--j|json-api : Create a JSON:API resource} + {--c|collection : Create a resource collection}'; /** * The console command description. @@ -92,18 +95,4 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Http\Resources'; } - - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the resource already exists'], - ['json-api', 'j', InputOption::VALUE_NONE, 'Create a JSON:API resource'], - ['collection', 'c', InputOption::VALUE_NONE, 'Create a resource collection'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/RouteCacheCommand.php b/src/Illuminate/Foundation/Console/RouteCacheCommand.php index 9b8632af..41bab73b 100644 --- a/src/Illuminate/Foundation/Console/RouteCacheCommand.php +++ b/src/Illuminate/Foundation/Console/RouteCacheCommand.php @@ -3,20 +3,22 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; +use Illuminate\Container\Container; use Illuminate\Contracts\Console\Kernel as ConsoleKernelContract; use Illuminate\Filesystem\Filesystem; use Illuminate\Routing\RouteCollection; +use Illuminate\Support\Facades\Facade; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'route:cache')] class RouteCacheCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'route:cache'; + protected $signature = 'route:cache'; /** * The console command description. @@ -63,7 +65,7 @@ public function handle() $route->prepareForSerialization(); } - $this->files->put( + $this->files->replace( $this->laravel->getCachedRoutesPath(), $this->buildRouteCacheFile($routes) ); @@ -92,6 +94,11 @@ protected function getFreshApplication() { return tap(require $this->laravel->bootstrapPath('app.php'), function ($app) { $app->make(ConsoleKernelContract::class)->bootstrap(); + + Facade::clearResolvedInstances(); + + Facade::setFacadeApplication($this->laravel); + Container::setInstance($this->laravel); }); } diff --git a/src/Illuminate/Foundation/Console/RouteClearCommand.php b/src/Illuminate/Foundation/Console/RouteClearCommand.php index b077f820..cdbc30f3 100644 --- a/src/Illuminate/Foundation/Console/RouteClearCommand.php +++ b/src/Illuminate/Foundation/Console/RouteClearCommand.php @@ -10,11 +10,11 @@ class RouteClearCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'route:clear'; + protected $signature = 'route:clear'; /** * The console command description. diff --git a/src/Illuminate/Foundation/Console/RouteListCommand.php b/src/Illuminate/Foundation/Console/RouteListCommand.php index 2c5f07c2..afc9c094 100644 --- a/src/Illuminate/Foundation/Console/RouteListCommand.php +++ b/src/Illuminate/Foundation/Console/RouteListCommand.php @@ -15,18 +15,29 @@ use ReflectionClass; use ReflectionFunction; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Terminal; #[AsCommand(name: 'route:list')] class RouteListCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'route:list'; + protected $signature = 'route:list + {--json : Output the route list as JSON} + {--method= : Filter the routes by method} + {--action= : Filter the routes by action} + {--name= : Filter the routes by name} + {--domain= : Filter the routes by domain} + {--middleware= : Filter the routes by middleware} + {--path= : Only show routes matching the given path pattern} + {--except-path= : Do not display the routes matching the given path pattern} + {--r|reverse : Reverse the ordering of the routes} + {--sort=uri : The column (domain, method, uri, name, action, middleware, definition) to sort by} + {--except-vendor : Do not display routes defined by vendor packages} + {--only-vendor : Only display routes defined by vendor packages}'; /** * The console command description. @@ -66,6 +77,7 @@ class RouteListCommand extends Command 'GET' => 'blue', 'HEAD' => '#6C7280', 'OPTIONS' => '#6C7280', + 'QUERY' => '#6C7280', 'POST' => 'yellow', 'PUT' => 'yellow', 'PATCH' => 'yellow', @@ -96,11 +108,15 @@ public function handle() } if (! $this->router->getRoutes()->count()) { - return $this->components->error("Your application doesn't have any routes."); + return $this->option('json') + ? $this->output->writeln('[]') + : $this->components->error("Your application doesn't have any routes."); } if (empty($routes = $this->getRoutes())) { - return $this->components->error("Your application doesn't have any routes matching the given criteria."); + return $this->option('json') + ? $this->output->writeln('[]') + : $this->components->error("Your application doesn't have any routes matching the given criteria."); } $this->displayRoutes($routes); @@ -236,6 +252,8 @@ protected function getMiddleware($route) * * @param \Illuminate\Routing\Route $route * @return string|null + * + * @throws \ReflectionException */ protected function getClosurePath(Route $route) { @@ -392,7 +410,7 @@ protected function forCli($routes) $routes = $routes->map( fn ($route) => array_merge($route, [ 'action' => $this->formatActionForCli($route), - 'method' => $route['method'] == 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS' ? 'ANY' : $route['method'], + 'method' => $route['method'] === implode('|', Router::$verbs) ? 'ANY' : $route['method'], 'uri' => $route['domain'] ? ($route['domain'].'/'.ltrim($route['uri'], '/')) : $route['uri'], ]), ); @@ -482,7 +500,7 @@ protected function formatActionForCli($route) $actionClass = explode('@', $action)[0]; if (class_exists($actionClass) && str_starts_with((new ReflectionClass($actionClass))->getFilename(), base_path('vendor'))) { - $actionCollection = new Collection(explode('\\', $action)); + $actionCollection = (new Stringable($action))->explode('\\'); return $name.$actionCollection->take(2)->implode('\\').' '.$actionCollection->last(); } @@ -530,27 +548,4 @@ public static function resolveTerminalWidthUsing($resolver) { static::$terminalWidthResolver = $resolver; } - - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['json', null, InputOption::VALUE_NONE, 'Output the route list as JSON'], - ['method', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by method'], - ['action', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by action'], - ['name', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by name'], - ['domain', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by domain'], - ['middleware', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by middleware'], - ['path', null, InputOption::VALUE_OPTIONAL, 'Only show routes matching the given path pattern'], - ['except-path', null, InputOption::VALUE_OPTIONAL, 'Do not display the routes matching the given path pattern'], - ['reverse', 'r', InputOption::VALUE_NONE, 'Reverse the ordering of the routes'], - ['sort', null, InputOption::VALUE_OPTIONAL, 'The column (domain, method, uri, name, action, middleware, definition) to sort by', 'uri'], - ['except-vendor', null, InputOption::VALUE_NONE, 'Do not display routes defined by vendor packages'], - ['only-vendor', null, InputOption::VALUE_NONE, 'Only display routes defined by vendor packages'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/RuleMakeCommand.php b/src/Illuminate/Foundation/Console/RuleMakeCommand.php index 02d4b540..5ff76d2e 100644 --- a/src/Illuminate/Foundation/Console/RuleMakeCommand.php +++ b/src/Illuminate/Foundation/Console/RuleMakeCommand.php @@ -4,17 +4,19 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:rule')] class RuleMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:rule'; + protected $signature = 'make:rule + {name : The name of the rule} + {--f|force : Create the class even if the rule already exists} + {--i|implicit : Generate an implicit rule}'; /** * The console command description. @@ -73,17 +75,4 @@ protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Rules'; } - - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the rule already exists'], - ['implicit', 'i', InputOption::VALUE_NONE, 'Generate an implicit rule'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/ScopeMakeCommand.php b/src/Illuminate/Foundation/Console/ScopeMakeCommand.php index c7db649e..15fed985 100644 --- a/src/Illuminate/Foundation/Console/ScopeMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ScopeMakeCommand.php @@ -4,17 +4,18 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:scope')] class ScopeMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:scope'; + protected $signature = 'make:scope + {name : The name of the scope} + {--f|force : Create the class even if the scope already exists}'; /** * The console command description. @@ -63,16 +64,4 @@ protected function getDefaultNamespace($rootNamespace) { return is_dir(app_path('Models')) ? $rootNamespace.'\\Models\\Scopes' : $rootNamespace.'\Scopes'; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the scope already exists'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/ServeCommand.php b/src/Illuminate/Foundation/Console/ServeCommand.php index 7033275e..822edf8b 100644 --- a/src/Illuminate/Foundation/Console/ServeCommand.php +++ b/src/Illuminate/Foundation/Console/ServeCommand.php @@ -10,7 +10,6 @@ use Illuminate\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; @@ -23,11 +22,15 @@ class ServeCommand extends Command use InteractsWithTime; /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'serve'; + protected $signature = 'serve + {--host= : The host address to serve the application on} + {--port= : The port to serve the application on} + {--tries=10 : The max number of ports to attempt to serve from} + {--no-reload : Do not reload the development server on .env file changes}'; /** * The console command description. @@ -93,6 +96,15 @@ class ServeCommand extends Command 'XDEBUG_SESSION', ]; + #[\Override] + protected function configureDefaults(): void + { + // The host and port default to whatever is configured via the environment, so + // they can't be expressed as static defaults in the signature above. + $this->getDefinition()->getOption('host')->setDefault(Env::get('SERVER_HOST', '127.0.0.1')); + $this->getDefinition()->getOption('port')->setDefault(Env::get('SERVER_PORT')); + } + /** {@inheritdoc} */ #[\Override] protected function initialize(InputInterface $input, OutputInterface $output): void @@ -235,7 +247,7 @@ protected function host() /** * Get the port for the command. * - * @return string + * @return int */ protected function port() { @@ -257,7 +269,7 @@ protected function port() */ protected function getHostAndPort() { - if (preg_match('/(\[.*\]):?([0-9]+)?/', $this->input->getOption('host'), $matches) !== false) { + if (preg_match('/(\[.*\]):?([0-9]+)?/', $this->input->getOption('host'), $matches) === 1) { return [ $matches[1] ?? $this->input->getOption('host'), $matches[2] ?? null, @@ -436,19 +448,4 @@ public static function getRequestPortFromLine($line) return (int) $matches[2]; } - - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['host', null, InputOption::VALUE_OPTIONAL, 'The host address to serve the application on', Env::get('SERVER_HOST', '127.0.0.1')], - ['port', null, InputOption::VALUE_OPTIONAL, 'The port to serve the application on', Env::get('SERVER_PORT')], - ['tries', null, InputOption::VALUE_OPTIONAL, 'The max number of ports to attempt to serve from', 10], - ['no-reload', null, InputOption::VALUE_NONE, 'Do not reload the development server on .env file changes'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/TestMakeCommand.php b/src/Illuminate/Foundation/Console/TestMakeCommand.php index e24766a9..ca93feb9 100644 --- a/src/Illuminate/Foundation/Console/TestMakeCommand.php +++ b/src/Illuminate/Foundation/Console/TestMakeCommand.php @@ -6,7 +6,6 @@ use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function Laravel\Prompts\select; @@ -15,11 +14,16 @@ class TestMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:test'; + protected $signature = 'make:test + {name : The name of the test} + {--f|force : Create the test even if the test already exists} + {--u|unit : Create a unit test} + {--pest : Create a Pest test} + {--phpunit : Create a PHPUnit test}'; /** * The console command description. @@ -100,21 +104,6 @@ protected function rootNamespace() return 'Tests'; } - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the test even if the test already exists'], - ['unit', 'u', InputOption::VALUE_NONE, 'Create a unit test'], - ['pest', null, InputOption::VALUE_NONE, 'Create a Pest test'], - ['phpunit', null, InputOption::VALUE_NONE, 'Create a PHPUnit test'], - ]; - } - /** * Interact further with the user if they were prompted for missing arguments. * diff --git a/src/Illuminate/Foundation/Console/TraitMakeCommand.php b/src/Illuminate/Foundation/Console/TraitMakeCommand.php index a66b1b1d..9d0f4c2d 100644 --- a/src/Illuminate/Foundation/Console/TraitMakeCommand.php +++ b/src/Illuminate/Foundation/Console/TraitMakeCommand.php @@ -4,17 +4,18 @@ use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:trait')] class TraitMakeCommand extends GeneratorCommand { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'make:trait'; + protected $signature = 'make:trait + {name : The name of the trait} + {--f|force : Create the trait even if the trait already exists}'; /** * The console command description. @@ -67,16 +68,4 @@ protected function getDefaultNamespace($rootNamespace) default => $rootNamespace, }; } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the trait even if the trait already exists'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/UpCommand.php b/src/Illuminate/Foundation/Console/UpCommand.php index 8bc9b54b..6236a858 100644 --- a/src/Illuminate/Foundation/Console/UpCommand.php +++ b/src/Illuminate/Foundation/Console/UpCommand.php @@ -11,11 +11,11 @@ class UpCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'up'; + protected $signature = 'up'; /** * The console command description. @@ -35,7 +35,7 @@ public function handle() if (! $this->laravel->maintenanceMode()->active()) { $this->components->info('Application is already up.'); - return 0; + return self::SUCCESS; } $this->laravel->maintenanceMode()->deactivate(); @@ -55,9 +55,9 @@ public function handle() $e->getMessage(), )); - return 1; + return self::FAILURE; } - return 0; + return self::SUCCESS; } } diff --git a/src/Illuminate/Foundation/Console/ViewClearCommand.php b/src/Illuminate/Foundation/Console/ViewClearCommand.php index 5d5d49a4..85698547 100644 --- a/src/Illuminate/Foundation/Console/ViewClearCommand.php +++ b/src/Illuminate/Foundation/Console/ViewClearCommand.php @@ -11,11 +11,11 @@ class ViewClearCommand extends Command { /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'view:clear'; + protected $signature = 'view:clear'; /** * The console command description. diff --git a/src/Illuminate/Foundation/Console/ViewMakeCommand.php b/src/Illuminate/Foundation/Console/ViewMakeCommand.php index 15cf1c3d..4641aba5 100644 --- a/src/Illuminate/Foundation/Console/ViewMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ViewMakeCommand.php @@ -9,7 +9,6 @@ use Illuminate\Support\Str; use Illuminate\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:view')] class ViewMakeCommand extends GeneratorCommand @@ -28,7 +27,10 @@ class ViewMakeCommand extends GeneratorCommand * * @var string */ - protected $name = 'make:view'; + protected $signature = 'make:view + {name : The name of the view} + {--extension=blade.php : The extension of the generated view} + {--f|force : Create the view even if the view already exists}'; /** * The type of file being generated. @@ -116,7 +118,7 @@ protected function resolveStubPath($stub) protected function getTestPath() { return base_path( - Str::of($this->testClassFullyQualifiedName()) + (new Stringable($this->testClassFullyQualifiedName())) ->replace('\\', '/') ->replaceFirst('Tests/Feature', 'tests/Feature') ->append('Test.php') @@ -157,7 +159,7 @@ protected function handleTestCreation($path): bool */ protected function testNamespace() { - return Str::of($this->testClassFullyQualifiedName()) + return (new Stringable($this->testClassFullyQualifiedName())) ->beforeLast('\\') ->value(); } @@ -169,7 +171,7 @@ protected function testNamespace() */ protected function testClassName() { - return Str::of($this->testClassFullyQualifiedName()) + return (new Stringable($this->testClassFullyQualifiedName())) ->afterLast('\\') ->append('Test') ->value(); @@ -182,15 +184,15 @@ protected function testClassName() */ protected function testClassFullyQualifiedName() { - $name = Str::of(Str::lower($this->getNameInput()))->replace('.'.$this->option('extension'), ''); + $name = (new Stringable(Str::lower($this->getNameInput())))->replace('.'.$this->option('extension'), ''); - $namespacedName = Str::of( + $namespacedName = (new Stringable( (new Stringable($name)) ->replace('/', ' ') ->explode(' ') ->map(fn ($part) => (new Stringable($part))->ucfirst()) ->implode('\\') - ) + )) ->replace(['-', '_'], ' ') ->explode(' ') ->map(fn ($part) => (new Stringable($part))->ucfirst()) @@ -220,7 +222,7 @@ protected function getTestStub() */ protected function testViewName() { - return Str::of($this->getNameInput()) + return (new Stringable($this->getNameInput())) ->replace('/', '.') ->lower() ->value(); @@ -241,17 +243,4 @@ protected function usingPest() (function_exists('\Pest\\version') && file_exists(base_path('tests').'/Pest.php')); } - - /** - * Get the console command arguments. - * - * @return array - */ - protected function getOptions() - { - return [ - ['extension', null, InputOption::VALUE_OPTIONAL, 'The extension of the generated view', 'blade.php'], - ['force', 'f', InputOption::VALUE_NONE, 'Create the view even if the view already exists'], - ]; - } } diff --git a/src/Illuminate/Foundation/Console/stubs/echo-js-mercure.stub b/src/Illuminate/Foundation/Console/stubs/echo-js-mercure.stub new file mode 100644 index 00000000..69adad37 --- /dev/null +++ b/src/Illuminate/Foundation/Console/stubs/echo-js-mercure.stub @@ -0,0 +1,6 @@ +import Echo from 'laravel-echo'; + +window.Echo = new Echo({ + broadcaster: 'mercure', + host: import.meta.env.VITE_MERCURE_HUB_URL, +}); diff --git a/src/Illuminate/Foundation/Console/stubs/maintenance-mode.stub b/src/Illuminate/Foundation/Console/stubs/maintenance-mode.stub index a90b8cde..461b3c81 100644 --- a/src/Illuminate/Foundation/Console/stubs/maintenance-mode.stub +++ b/src/Illuminate/Foundation/Console/stubs/maintenance-mode.stub @@ -8,6 +8,21 @@ if (! file_exists($down = __DIR__.'/down')) { // Decode the "down" file's JSON... $data = json_decode(file_get_contents($down), true); +$expectsJson = (function () { + $acceptable = array_map('trim', explode(',', $_SERVER['HTTP_ACCEPT'] ?? '')); + + $firstAcceptable = strtolower(strtok($acceptable[0] ?? '', ';')); + + if (str_contains($firstAcceptable, '/json') || str_contains($firstAcceptable, '+json')) { + return true; + } + + return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && + strcasecmp($_SERVER['HTTP_X_REQUESTED_WITH'], 'XMLHttpRequest') === 0 && + (! isset($_SERVER['HTTP_X_PJAX']) || strcasecmp($_SERVER['HTTP_X_PJAX'], 'true') !== 0) && + in_array($firstAcceptable, ['', '*/*', '*'], true); +})(); + // Allow framework to handle request if no prerendered template... if (! isset($data['template'])) { return; @@ -37,7 +52,7 @@ if (isset($data['except'])) { } // Allow framework to handle maintenance mode bypass route... -if (isset($data['secret']) && $_SERVER['REQUEST_URI'] === '/'.$data['secret']) { +if (isset($data['secret']) && hash_equals('/'.$data['secret'], $_SERVER['REQUEST_URI'] ?? '')) { return; } @@ -54,6 +69,11 @@ if (isset($_COOKIE['laravel_maintenance']) && isset($data['secret'])) { } } +// Allow framework to handle requests expecting a JSON response... +if ($expectsJson) { + return; +} + // Redirect to the proper path if necessary... if (isset($data['redirect']) && $_SERVER['REQUEST_URI'] !== $data['redirect']) { http_response_code(302); diff --git a/src/Illuminate/Foundation/Console/stubs/resource-json-api.stub b/src/Illuminate/Foundation/Console/stubs/resource-json-api.stub index fe113770..d3635fd4 100644 --- a/src/Illuminate/Foundation/Console/stubs/resource-json-api.stub +++ b/src/Illuminate/Foundation/Console/stubs/resource-json-api.stub @@ -2,7 +2,6 @@ namespace {{ namespace }}; -use Illuminate\Http\Request; use Illuminate\Http\Resources\JsonApi\JsonApiResource; class {{ class }} extends JsonApiResource diff --git a/src/Illuminate/Foundation/DevCommand.php b/src/Illuminate/Foundation/DevCommand.php new file mode 100644 index 00000000..5cd4567a --- /dev/null +++ b/src/Illuminate/Foundation/DevCommand.php @@ -0,0 +1,166 @@ +name ??= self::nameFromCommand($command); + } + + /** + * Derive the name from a command string. + * + * @param string $command + * @return string + */ + public static function nameFromCommand(string $command): string + { + return strstr($command, ' ', true) ?: $command; + } + + /** + * Get the command name. + * + * @return string + */ + public function name(): string + { + return $this->name; + } + + /** + * Get the command priority. + * + * @return int + */ + public function priority(): int + { + return $this->priority; + } + + /** + * Set the command color. + * + * @param string $color + * @return self + */ + public function color(string $color): self + { + $this->color = $color; + + return $this; + } + + /** + * Set the command color to blue. + * + * @return self + */ + public function blue(): self + { + return $this->color(DevCommandColor::BLUE->value); + } + + /** + * Set the command color to purple. + * + * @return self + */ + public function purple(): self + { + return $this->color(DevCommandColor::PURPLE->value); + } + + /** + * Set the command color to pink. + * + * @return self + */ + public function pink(): self + { + return $this->color(DevCommandColor::PINK->value); + } + + /** + * Set the command color to orange. + * + * @return self + */ + public function orange(): self + { + return $this->color(DevCommandColor::ORANGE->value); + } + + /** + * Set the command color to green. + * + * @return self + */ + public function green(): self + { + return $this->color(DevCommandColor::GREEN->value); + } + + /** + * Set the command color to yellow. + * + * @return self + */ + public function yellow(): self + { + return $this->color(DevCommandColor::YELLOW->value); + } + + /** + * Get the command as an array. + * + * @return array{command: string, name: string, color: string|null, source: array{'file': string, 'line': int, 'class'?: string, 'function'?: string}, priority: int} + */ + public function toArray(): array + { + return [ + 'command' => $this->command, + 'name' => $this->name, + 'color' => $this->color, + 'source' => $this->source, + 'priority' => $this->priority, + ]; + } +} diff --git a/src/Illuminate/Foundation/DevCommandColor.php b/src/Illuminate/Foundation/DevCommandColor.php new file mode 100644 index 00000000..7a67b3ad --- /dev/null +++ b/src/Illuminate/Foundation/DevCommandColor.php @@ -0,0 +1,13 @@ + + */ + protected static $only = []; + + /** + * The names of commands that should be excluded when running the "dev" command. + * + * @var array + */ + protected static $except = []; + + /** + * The order in which named commands should run when running the "dev" command. + * + * @var array + */ + protected static $order = []; + + /** + * The mode in which the "dev" command should run. + * + * @var DevCommandMode + */ + protected static DevCommandMode $mode = DevCommandMode::TABS; + + /** + * Whether to include timestamps in the output of the "dev" command. + * + * @var bool + */ + protected static $withTimestamps = false; + + /** + * Whether to automatically restart a "dev" command when it fails. + * + * @var bool + */ + protected static $autoRestart = true; + + /** + * Whether to exclude vendor commands. + * + * @var bool + */ + protected static $withoutVendorCommands = false; + + /** + * Whether to exclude the framework's default commands. + * + * @var bool + */ + protected static $withoutDefaultCommands = false; + + /** + * How many lines of output to buffer for each command when running in tabbed mode. + * + * @var int|null + */ + protected static ?int $bufferSize = null; + + /** + * How many lines of output to buffer total when running in stream mode. + * + * @var int|null + */ + protected static ?int $streamBufferSize = null; + + /** + * Register the default development commands. + * + * @return void + */ + public static function registerDefaults() + { + if (! app()->runningInConsole()) { + return; + } + + self::artisan('serve', 'server'); + self::artisan('queue:listen --tries=1 --timeout=0', 'queue'); + + if (function_exists('pcntl_fork') && app()->providerIsLoaded(PailServiceProvider::class)) { + self::artisan('pail --timeout=0', 'logs'); + } + + if (File::exists(base_path('package.json'))) { + self::node('dev', 'vite'); + } + } + + /** + * Register a development command. + * + * @param string $command + * @param string|null $name + * @return DevCommand + */ + public static function register(string $command, ?string $name = null): DevCommand + { + if (! app()->runningInConsole()) { + return new DevCommand('', [], ''); + } + + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $source = self::resolveSource($trace); + $priority = self::resolvePriority($trace); + + $devCommand = new DevCommand($command, $source, $name, $priority); + + $existing = self::$commands[$devCommand->name()] ?? null; + + if (! $existing || $devCommand->priority() >= $existing->priority()) { + self::$commands[$devCommand->name()] = $devCommand; + } + + return $devCommand; + } + + /** + * Registers an Artisan command, automatically prefixing it with "php artisan". + * + * @param string $command + * @param string|null $name + * @return DevCommand + */ + public static function artisan(string $command, ?string $name = null): DevCommand + { + return self::register("php artisan {$command}", $name ?? DevCommand::nameFromCommand($command)); + } + + /** + * Registers a Node command, automatically prefixing it with the detected package manager's run command. + * + * @param string $command + * @param string|null $name + * @return DevCommand + */ + public static function node(string $command, ?string $name = null): DevCommand + { + return self::register(self::getPackageManager()->getRunCommand($command), $name ?? DevCommand::nameFromCommand($command)); + } + + /** + * Registers a Node command, automatically prefixing it with the detected package manager's exec command. + * + * @param string $command + * @param string|null $name + * @return DevCommand + */ + public static function nodeExec(string $command, ?string $name = null): DevCommand + { + return self::register(self::getPackageManager()->getExecCommand($command), $name ?? DevCommand::nameFromCommand($command)); + } + + /** + * Get the registered development commands. + * + * @return DevCommandArray[] + */ + public static function commands(): array + { + $commands = []; + + foreach (self::$commands as $command) { + if (self::$withoutVendorCommands && $command->priority() === DevCommand::PRIORITY_VENDOR) { + continue; + } + + if (self::$withoutDefaultCommands && $command->priority() === DevCommand::PRIORITY_DEFAULT) { + continue; + } + + $cmd = $command->toArray(); + + if ((! empty(self::$only) && ! in_array($cmd['name'], self::$only)) || in_array($cmd['name'], self::$except)) { + continue; + } + + $commands[] = $cmd; + } + + $commands = self::applyOrder($commands); + + return self::fillInEmptyColors($commands); + } + + /** + * Set the mode to inline, where all commands are run in the same terminal window. + * + * No-op on Windows. + * + * @return void + */ + public static function inline(): void + { + self::$mode = DevCommandMode::INLINE; + } + + /** + * Set the mode to stream, where all commands are run in the same terminal window, but their output is interactive within a TUI. + * + * No-op on Windows. + * + * @return void + */ + public static function stream(): void + { + self::$mode = DevCommandMode::STREAM; + } + + /** + * Set the mode to tabs, where each command is run in its own terminal tab. + * + * No-op on Windows. + * + * @return void + */ + public static function tabs(): void + { + self::$mode = DevCommandMode::TABS; + } + + /** + * Get the mode in which the "dev" command should run. + * + * @return DevCommandMode + */ + public static function mode(): DevCommandMode + { + return self::$mode; + } + + /** + * Enable timestamps in the output of the "dev" command. + * + * @return void + */ + public static function withTimestamps(): void + { + self::$withTimestamps = true; + } + + /** + * Determine if timestamps should be included in the output of the "dev" command. + * + * @return bool + */ + public static function shouldIncludeTimestamps(): bool + { + return self::$withTimestamps; + } + + /** + * Disable automatic restart of a "dev" command when it fails. + * + * @return void + */ + public static function disableAutoRestart(): void + { + self::$autoRestart = false; + } + + /** + * Determine if a "dev" command should automatically restart when it fails. + * + * @return bool + */ + public static function shouldAutoRestart(): bool + { + return self::$autoRestart; + } + + /** + * Set the number of lines of output to buffer for each command when running in tabbed mode. + * + * No-op on Windows. + * + * @param int $lines + * @return void + */ + public static function bufferSize(int $lines): void + { + self::$bufferSize = $lines; + } + + /** + * Get the number of lines of output to buffer for each command when running in tabbed mode. + * + * @return int|null + */ + public static function getBufferSize(): ?int + { + return self::$bufferSize; + } + + /** + * Set the number of lines of output to buffer total when running in stream mode. + * + * No-op on Windows. + * + * @param int $lines + * @return void + */ + public static function streamBufferSize(int $lines): void + { + self::$streamBufferSize = $lines; + } + + /** + * Get the number of lines of output to buffer total when running in stream mode. + * + * @return int|null + */ + public static function getStreamBufferSize(): ?int + { + return self::$streamBufferSize; + } + + /** + * Sort the given commands. + * + * @param array $commands + * @return array + */ + protected static function applyOrder(array $commands): array + { + if (empty(self::$order)) { + return $commands; + } + + $positions = array_flip(self::$order); + + $commands = Arr::sort($commands, fn ($command) => $positions[$command['name']] ?? PHP_INT_MAX); + + return array_values($commands); + } + + /** + * Fill in any empty colors in the given commands array, ensuring each command has a color assigned. + * + * @param array $commands + * @return array + */ + protected static function fillInEmptyColors(array $commands): array + { + foreach ($commands as &$command) { + if (empty($command['color'])) { + $command['color'] = self::getColor($commands); + } + } + + return $commands; + } + + /** + * Get a color for a command, ensuring that colors are reused only after all available colors have been used at least once. + * + * @param array $commands + * @return string + */ + protected static function getColor(array $commands): string + { + $available = array_values(array_diff( + $colors = array_map(fn ($color) => $color->value, DevCommandColor::cases()), + $existing = array_values(array_filter(array_column($commands, 'color'))) + )); + + return $available[0] ?? $colors[self::$colorCount++ % count($colors)]; + } + + /** + * Resolve the first external caller frame from a debug backtrace. + * + * @param array $trace + * @return array{'file': string, 'line': int, 'class'?: string, 'function'?: string} + */ + protected static function resolveSource(array $trace): array + { + foreach ($trace as $frame) { + if (($frame['file'] ?? null) === __FILE__) { + continue; + } + + if (($frame['class'] ?? null) === self::class) { + continue; + } + + return $frame; + } + + return []; + } + + /** + * Determine the registration priority from a debug backtrace. + * + * @param array $trace + * @return int + */ + protected static function resolvePriority(array $trace): int + { + foreach ($trace as $frame) { + $file = $frame['file'] ?? null; + $class = $frame['class'] ?? null; + + if ($file === __FILE__) { + continue; + } + + if ($class === self::class && ($frame['function'] ?? null) === 'registerDefaults') { + return DevCommand::PRIORITY_DEFAULT; + } + + if (! $file && $class) { + $file = (new ReflectionClass($class))->getFileName(); + } + + if (! $file || $file === base_path('artisan')) { + continue; + } + + if (! str_contains($file, base_path('vendor'))) { + return DevCommand::PRIORITY_USERLAND; + } + } + + return DevCommand::PRIORITY_VENDOR; + } + + /** + * Set the commands that should be included when running the "dev" command. + * + * @param string ...$names + * @return void + */ + public static function only(...$names): void + { + self::$only = $names; + } + + /** + * Set the commands that should be excluded when running the "dev" command. + * + * @param string ...$names + * @return void + */ + public static function except(...$names): void + { + self::$except = $names; + } + + /** + * Set the order in which named commands should run when running the "dev" command. + * + * @param array $names + * @return void + */ + public static function order(array $names): void + { + self::$order = $names; + } + + /** + * Exclude any commands from the vendor directory. + * + * @return void + */ + public static function withoutVendorCommands(): void + { + self::$withoutVendorCommands = true; + } + + /** + * Exclude the framework's default commands. + * + * @return void + */ + public static function withoutDefaultCommands(): void + { + self::$withoutDefaultCommands = true; + } + + /** + * Resolve and return the NodePackageManager instance. + * + * @return NodePackageManager + */ + protected static function getPackageManager(): NodePackageManager + { + return self::$packageManager ??= new NodePackageManager(); + } +} diff --git a/src/Illuminate/Foundation/Exceptions/Handler.php b/src/Illuminate/Foundation/Exceptions/Handler.php index 811dc10b..48d14481 100644 --- a/src/Illuminate/Foundation/Exceptions/Handler.php +++ b/src/Illuminate/Foundation/Exceptions/Handler.php @@ -17,7 +17,6 @@ use Illuminate\Contracts\Foundation\ExceptionRenderer; use Illuminate\Contracts\Support\Responsable; use Illuminate\Database\Eloquent\ModelNotFoundException; -use Illuminate\Database\MultipleRecordsFoundException; use Illuminate\Database\RecordNotFoundException; use Illuminate\Database\RecordsNotFoundException; use Illuminate\Foundation\Exceptions\Renderer\Renderer; @@ -33,7 +32,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Lottery; use Illuminate\Support\Reflector; -use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use Illuminate\Support\Traits\ReflectsClosures; use Illuminate\Support\ViewErrorBag; use Illuminate\Validation\ValidationException; @@ -79,6 +78,20 @@ class Handler implements ExceptionHandlerContract */ protected $dontReportCallbacks = []; + /** + * A list of the exception types that should stop job retries. + * + * @var array> + */ + protected $dontRetry = []; + + /** + * The callbacks that inspect exceptions to determine if they should stop job retries. + * + * @var array + */ + protected $dontRetryCallbacks = []; + /** * The callbacks that should be used during reporting. * @@ -161,7 +174,6 @@ class Handler implements ExceptionHandlerContract HttpException::class, HttpResponseException::class, ModelNotFoundException::class, - MultipleRecordsFoundException::class, OriginMismatchException::class, RecordNotFoundException::class, RecordsNotFoundException::class, @@ -326,6 +338,53 @@ public function ignore(array|string $exceptions) return $this; } + /** + * Indicate that the given exception type should stop job retries. + * + * @param array|string $exceptions + * @return $this + */ + public function dontRetry(array|string $exceptions) + { + $exceptions = Arr::wrap($exceptions); + + $this->dontRetry = array_values(array_unique(array_merge($this->dontRetry, $exceptions))); + + return $this; + } + + /** + * Register a callback to determine if an exception should stop job retries. + * + * @param (callable(\Throwable): bool) $dontRetryWhen + * @return $this + */ + public function dontRetryWhen(callable $dontRetryWhen) + { + if (! $dontRetryWhen instanceof Closure) { + $dontRetryWhen = Closure::fromCallable($dontRetryWhen); + } + + $this->dontRetryCallbacks[] = $dontRetryWhen; + + return $this; + } + + /** + * Determine if the exception should stop job retries. + * + * @param \Throwable $e + * @return bool + */ + public function shouldStopRetries(Throwable $e) + { + if (! is_null(Arr::first($this->dontRetry, fn ($type) => $e instanceof $type))) { + return true; + } + + return array_any($this->dontRetryCallbacks, fn ($dontRetryCallback) => $dontRetryCallback($e) === true); + } + /** * Indicate that the given attributes should never be flashed to the session on validation errors. * @@ -536,18 +595,29 @@ public function stopIgnoring(array|string $exceptions) $exceptions = Arr::wrap($exceptions); $this->dontReport = (new Collection($this->dontReport)) - ->reject(fn ($ignored) => in_array($ignored, $exceptions)) + ->diff($exceptions) ->values() ->all(); $this->internalDontReport = (new Collection($this->internalDontReport)) - ->reject(fn ($ignored) => in_array($ignored, $exceptions)) + ->diff($exceptions) ->values() ->all(); return $this; } + /** + * Create the context array for logging the given exception. + * + * @param \Throwable $e + * @return array + */ + public function contextForException(Throwable $e) + { + return $this->buildExceptionContext($e); + } + /** * Create the context array for logging the given exception. * @@ -823,9 +893,11 @@ protected function convertValidationExceptionToResponse(ValidationException $e, */ protected function invalid($request, ValidationException $exception) { + $errorBag = $request->input('_error_bag'); + return redirect($exception->redirectTo ?? url()->previous()) ->withInput(Arr::except($request->input(), $this->dontFlash)) - ->withErrors($exception->errors(), $request->input('_error_bag', $exception->errorBag)); + ->withErrors($exception->errors(), is_string($errorBag) ? $errorBag : $exception->errorBag); } /** @@ -1086,7 +1158,7 @@ protected function convertExceptionToArray(Throwable $e) public function renderForConsole($output, Throwable $e) { if ($e instanceof CommandNotFoundException) { - $message = Str::of($e->getMessage())->explode('.')->first(); + $message = (new Stringable($e->getMessage()))->explode('.')->first(); if (! empty($alternatives = $e->getAlternatives())) { $message .= '. Did you mean one of these?'; diff --git a/src/Illuminate/Foundation/Exceptions/Renderer/Exception.php b/src/Illuminate/Foundation/Exceptions/Renderer/Exception.php index 51faa502..fc6b5d19 100644 --- a/src/Illuminate/Foundation/Exceptions/Renderer/Exception.php +++ b/src/Illuminate/Foundation/Exceptions/Renderer/Exception.php @@ -254,7 +254,7 @@ public function applicationRouteContext() /** * Get the application's route parameters context. * - * @return array|null + * @return string|null */ public function applicationRouteParametersContext() { diff --git a/src/Illuminate/Foundation/Exceptions/Renderer/Mappers/BladeMapper.php b/src/Illuminate/Foundation/Exceptions/Renderer/Mappers/BladeMapper.php index 31fc140c..2252c516 100644 --- a/src/Illuminate/Foundation/Exceptions/Renderer/Mappers/BladeMapper.php +++ b/src/Illuminate/Foundation/Exceptions/Renderer/Mappers/BladeMapper.php @@ -106,7 +106,7 @@ public function map(FlattenException $exception) * @param string $compiledPath * @return string|null */ - protected function findCompiledView(string $compiledPath) + public function findCompiledView(string $compiledPath) { return once(fn () => $this->getKnownPaths())[$compiledPath] ?? null; } @@ -166,7 +166,7 @@ protected function filterViewData(array $data) * @param int $compiledLineNumber * @return int */ - protected function detectLineNumber(string $filename, int $compiledLineNumber) + public function detectLineNumber(string $filename, int $compiledLineNumber) { $map = $this->compileSourcemap((string) file_get_contents($filename)); diff --git a/src/Illuminate/Foundation/Http/FormRequest.php b/src/Illuminate/Foundation/Http/FormRequest.php index 60a833a4..3dd27327 100644 --- a/src/Illuminate/Foundation/Http/FormRequest.php +++ b/src/Illuminate/Foundation/Http/FormRequest.php @@ -136,28 +136,30 @@ protected function getValidatorInstance() */ protected function configureFromAttributes() { - $reflection = new ReflectionClass($this); - - if ($reflection->getAttributes(StopOnFirstFailure::class) !== []) { + if ($this->nearestClassWithAttribute([StopOnFirstFailure::class], ['stopOnFirstFailure'])) { $this->stopOnFirstFailure = true; } - $redirectTo = $reflection->getAttributes(RedirectTo::class); + $reflection = $this->nearestClassWithAttribute( + [RedirectTo::class, RedirectToRoute::class], ['redirect', 'redirectRoute', 'redirectAction'] + ); - if ($redirectTo !== []) { - $this->redirect = $redirectTo[0]->newInstance()->url; - } + if ($reflection) { + $redirectTo = $reflection->getAttributes(RedirectTo::class); - $redirectToRoute = $reflection->getAttributes(RedirectToRoute::class); + if ($redirectTo !== []) { + $this->redirect = $redirectTo[0]->newInstance()->url; + } - if ($redirectToRoute !== []) { - $this->redirectRoute = $redirectToRoute[0]->newInstance()->route; - } + $redirectToRoute = $reflection->getAttributes(RedirectToRoute::class); - $errorBag = $reflection->getAttributes(ErrorBag::class); + if ($redirectToRoute !== []) { + $this->redirectRoute = $redirectToRoute[0]->newInstance()->route; + } + } - if ($errorBag !== []) { - $this->errorBag = $errorBag[0]->newInstance()->name; + if ($reflection = $this->nearestClassWithAttribute([ErrorBag::class], ['errorBag'])) { + $this->errorBag = $reflection->getAttributes(ErrorBag::class)[0]->newInstance()->name; } } @@ -214,10 +216,10 @@ protected function validationRules() */ protected function shouldFailOnUnknownFields(): bool { - $failOnUnknownFields = (new ReflectionClass($this))->getAttributes(FailOnUnknownFields::class); + $reflection = $this->nearestClassWithAttribute([FailOnUnknownFields::class]); - return $failOnUnknownFields !== [] - ? $failOnUnknownFields[0]->newInstance()->value + return $reflection + ? $reflection->getAttributes(FailOnUnknownFields::class)[0]->newInstance()->value : static::$globalFailOnUnknownFields; } @@ -233,8 +235,12 @@ protected function validateNoUnknownFields(Validator $validator): void $input = $this->isJson() ? $this->json()->all() : $this->request->all(); - foreach (array_keys(Arr::dot($input)) as $inputKey) { + $input = Arr::except($input, ['_token', '_method']); + + foreach ($this->dotInputKeys($input) as $inputKey) { if (! $this->isKnownField($inputKey, $allowedKeys)) { + $inputKey = str_replace('\.', '.', $inputKey); + $validator->errors()->add($inputKey, trans('validation.prohibited', [ 'attribute' => str_replace('_', ' ', $inputKey), ])); @@ -242,6 +248,30 @@ protected function validateNoUnknownFields(Validator $validator): void } } + /** + * Flatten the given input's keys into dot notation, escaping literal dots within keys. + * + * @param array $input + * @param string $prefix + * @return array + */ + protected function dotInputKeys(array $input, string $prefix = ''): array + { + $keys = []; + + foreach ($input as $key => $value) { + $key = $prefix.str_replace('.', '\.', (string) $key); + + if (is_array($value) && $value !== []) { + $keys = array_merge($keys, $this->dotInputKeys($value, $key.'.')); + } else { + $keys[] = $key; + } + } + + return $keys; + } + /** * Determine if the given input key is an allowed key based on the validation rules. * @@ -262,7 +292,7 @@ protected function isKnownField(string $inputKey, array $allowedKeys): bool } if (str_contains($ruleKey, '*')) { - $pattern = '/^'.str_replace('\*', '[^.]+', preg_quote($ruleKey, '/')).'$/'; + $pattern = '/^'.str_replace('\*', '(?:[^.\\\\]|\\\\.)+', preg_quote($ruleKey, '/')).'$/'; if (preg_match($pattern, $inputKey)) { return true; @@ -384,6 +414,35 @@ public function attributes() return []; } + /** + * Get the nearest class in the request's hierarchy that applies any of the given attributes. + * + * @param array $attributes + * @param array $properties + * @return \ReflectionClass<\Illuminate\Foundation\Http\FormRequest>|null + */ + protected function nearestClassWithAttribute(array $attributes, array $properties = []) + { + $reflection = new ReflectionClass($this); + + do { + foreach ($attributes as $attribute) { + if ($reflection->getAttributes($attribute) !== []) { + return $reflection; + } + } + + foreach ($properties as $property) { + if ($reflection->hasProperty($property) && + $reflection->getProperty($property)->class === $reflection->name) { + return null; + } + } + } while (($reflection = $reflection->getParentClass()) && $reflection->name !== self::class); + + return null; + } + /** * Enable or disable unknown-field rejection globally for all form requests. * diff --git a/src/Illuminate/Foundation/Http/HealthCheck.php b/src/Illuminate/Foundation/Http/HealthCheck.php new file mode 100644 index 00000000..b4931b28 --- /dev/null +++ b/src/Illuminate/Foundation/Http/HealthCheck.php @@ -0,0 +1,46 @@ +hasDebugModeEnabled()) { + throw $e; + } + + report($e); + + $exception = $e->getMessage(); + } + + $status = $exception ? 500 : 200; + + if ($request->expectsJson()) { + return response()->json([ + 'status' => $exception ? 'down' : 'up', + ], $status); + } + + return response(View::file(__DIR__.'/../resources/health-up.blade.php', [ + 'exception' => $exception, + ]), status: $status); + } +} diff --git a/src/Illuminate/Foundation/Http/Kernel.php b/src/Illuminate/Foundation/Http/Kernel.php index 3760a4d8..b2ce376f 100644 --- a/src/Illuminate/Foundation/Http/Kernel.php +++ b/src/Illuminate/Foundation/Http/Kernel.php @@ -485,23 +485,21 @@ public function addToMiddlewarePriorityAfter($after, $middleware) protected function addToMiddlewarePriorityRelative($existing, $middleware, $after = true) { if (! in_array($middleware, $this->middlewarePriority)) { - $index = $after ? 0 : count($this->middlewarePriority); + $index = null; foreach ((array) $existing as $existingMiddleware) { if (in_array($existingMiddleware, $this->middlewarePriority)) { $middlewareIndex = array_search($existingMiddleware, $this->middlewarePriority); - if ($after && $middlewareIndex > $index) { + if ($after && (is_null($index) || $middlewareIndex >= $index)) { $index = $middlewareIndex + 1; - } elseif ($after === false && $middlewareIndex < $index) { + } elseif ($after === false && (is_null($index) || $middlewareIndex < $index)) { $index = $middlewareIndex; } } } - if ($index === 0 && $after === false) { - array_unshift($this->middlewarePriority, $middleware); - } elseif (($after && $index === 0) || $index === count($this->middlewarePriority)) { + if (is_null($index) || $index === count($this->middlewarePriority)) { $this->middlewarePriority[] = $middleware; } else { array_splice($this->middlewarePriority, $index, 0, $middleware); @@ -577,7 +575,7 @@ protected function renderException($request, Throwable $e) /** * Get the application's global middleware. * - * @return array + * @return array */ public function getGlobalMiddleware() { @@ -602,7 +600,7 @@ public function setGlobalMiddleware(array $middleware) /** * Get the application's route middleware groups. * - * @return array + * @return array> */ public function getMiddlewareGroups() { diff --git a/src/Illuminate/Foundation/Http/MaintenanceModeBypassCookie.php b/src/Illuminate/Foundation/Http/MaintenanceModeBypassCookie.php index 9767fe58..5527b1c8 100644 --- a/src/Illuminate/Foundation/Http/MaintenanceModeBypassCookie.php +++ b/src/Illuminate/Foundation/Http/MaintenanceModeBypassCookie.php @@ -36,7 +36,7 @@ public static function isValid(string $cookie, string $key) return is_array($payload) && is_numeric($payload['expires_at'] ?? null) && - isset($payload['mac']) && + is_string($payload['mac'] ?? null) && hash_equals(hash_hmac('sha256', $payload['expires_at'], $key), $payload['mac']) && (int) $payload['expires_at'] >= Carbon::now()->getTimestamp(); } diff --git a/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php b/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php index c3944d6c..47a4e900 100644 --- a/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php +++ b/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Http\Middleware\Concerns\ExcludesPaths; use Illuminate\Support\Arr; use Symfony\Component\HttpKernel\Exception\HttpException; +use TypeError; class PreventRequestsDuringMaintenance { @@ -64,7 +65,7 @@ public function handle($request, Closure $next) if ($this->app->maintenanceMode()->active()) { try { $data = $this->app->maintenanceMode()->data(); - } catch (ErrorException $exception) { + } catch (ErrorException|TypeError $exception) { if (! $this->app->maintenanceMode()->active()) { return $next($request); } @@ -72,7 +73,7 @@ public function handle($request, Closure $next) throw $exception; } - if (isset($data['secret']) && $request->path() === $data['secret']) { + if (is_string($data['secret'] ?? null) && hash_equals($data['secret'], $request->path())) { return $this->bypassResponse($data['secret']); } @@ -80,7 +81,7 @@ public function handle($request, Closure $next) return $next($request); } - if (isset($data['redirect'])) { + if (isset($data['redirect']) && ! $request->expectsJson()) { $path = $data['redirect'] === '/' ? $data['redirect'] : trim($data['redirect'], '/'); @@ -90,7 +91,7 @@ public function handle($request, Closure $next) } } - if (isset($data['template'])) { + if (isset($data['template']) && ! $request->expectsJson()) { return response( $data['template'], $data['status'] ?? 503, diff --git a/src/Illuminate/Foundation/Http/Middleware/PreventStaleDatabaseReads.php b/src/Illuminate/Foundation/Http/Middleware/PreventStaleDatabaseReads.php new file mode 100644 index 00000000..9e2c21a4 --- /dev/null +++ b/src/Illuminate/Foundation/Http/Middleware/PreventStaleDatabaseReads.php @@ -0,0 +1,54 @@ +manager->connection(); + + $connection->useWriteConnectionWhenReading( + $request->session()->get('_database_modified_at', 0) >= Date::now()->subSeconds((int) $seconds)->getTimestamp() + ); + + $response = $next($request); + + if ($connection->hasModifiedRecords()) { + $request->session()->put('_database_modified_at', Date::now()->getTimestamp()); + } + + return $response; + } +} diff --git a/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php b/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php index 40dbcd08..2e62e1e2 100644 --- a/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php +++ b/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php @@ -3,10 +3,25 @@ namespace Illuminate\Foundation\Http\Middleware; use Closure; +use Illuminate\Http\Exceptions\PostTooLargeException; use Symfony\Component\HttpFoundation\ParameterBag; class TransformsRequest { + /** + * The maximum cumulative size of the keys processed per request, in bytes. + * + * @var int + */ + protected $maxKeyBytes = 20 * 1024 * 1024; + + /** + * The cumulative size of the keys processed for the current request. + * + * @var int + */ + protected $keyBytes = 0; + /** * Handle an incoming request. * @@ -16,6 +31,8 @@ class TransformsRequest */ public function handle($request, Closure $next) { + $this->keyBytes = 0; + $this->clean($request); return $next($request); @@ -58,8 +75,21 @@ protected function cleanParameterBag(ParameterBag $bag) */ protected function cleanArray(array $data, $keyPrefix = '') { + $prefixLength = strlen($keyPrefix); + foreach ($data as $key => $value) { - $data[$key] = $this->cleanValue($keyPrefix.$key, $value); + $this->keyBytes += $prefixLength + strlen((string) $key); + + if ($this->keyBytes > $this->maxKeyBytes) { + throw new PostTooLargeException('The cumulative size of the input keys is too large.'); + } + + $cleaned = $this->cleanValue($keyPrefix.$key, $value); + + // Avoid copying arrays when none of their values have changed... + if ($cleaned !== $value) { + $data[$key] = $cleaned; + } } return $data; diff --git a/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php b/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php index 942b476c..f329984c 100644 --- a/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php +++ b/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php @@ -60,9 +60,13 @@ public function handle($request, Closure $next) */ protected function transform($key, $value) { + if (! is_string($value)) { + return $value; + } + $except = array_merge($this->except, static::$neverTrim); - if ($this->shouldSkip($key, $except) || ! is_string($value)) { + if ($this->shouldSkip($key, $except)) { return $value; } diff --git a/src/Illuminate/Foundation/MaintenanceModeManager.php b/src/Illuminate/Foundation/MaintenanceModeManager.php index 4d233f44..bd859f35 100644 --- a/src/Illuminate/Foundation/MaintenanceModeManager.php +++ b/src/Illuminate/Foundation/MaintenanceModeManager.php @@ -16,6 +16,16 @@ protected function createFileDriver(): FileBasedMaintenanceMode return new FileBasedMaintenanceMode(); } + /** + * Create an instance of the array based maintenance driver. + * + * @return \Illuminate\Foundation\ArrayMaintenanceMode + */ + protected function createArrayDriver(): ArrayMaintenanceMode + { + return new ArrayMaintenanceMode(); + } + /** * Create an instance of the cache based maintenance driver. * diff --git a/src/Illuminate/Foundation/PackageManifest.php b/src/Illuminate/Foundation/PackageManifest.php index d2494db2..c899e9a2 100644 --- a/src/Illuminate/Foundation/PackageManifest.php +++ b/src/Illuminate/Foundation/PackageManifest.php @@ -40,7 +40,7 @@ class PackageManifest /** * The loaded manifest array. * - * @var array + * @var array|null */ public $manifest; @@ -134,8 +134,8 @@ public function build() })->each(function ($configuration) use (&$ignore) { $ignore = array_merge($ignore, $configuration['dont-discover'] ?? []); })->reject(function ($configuration, $package) use ($ignore, $ignoreAll) { - return $ignoreAll || in_array($package, $ignore); - })->filter()->all()); + return $ignoreAll || in_array($package, $ignore) || empty($configuration); + })->all()); } /** diff --git a/src/Illuminate/Foundation/ProviderRepository.php b/src/Illuminate/Foundation/ProviderRepository.php index df76e054..49fc6f2c 100755 --- a/src/Illuminate/Foundation/ProviderRepository.php +++ b/src/Illuminate/Foundation/ProviderRepository.php @@ -99,7 +99,7 @@ public function loadManifest() /** * Determine if the manifest should be compiled. * - * @param array $manifest + * @param array|null $manifest * @param array $providers * @return bool */ diff --git a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php index 09397ac7..f0cd6b2f 100755 --- a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php @@ -45,6 +45,8 @@ use Illuminate\Foundation\Console\ConfigPublishCommand; use Illuminate\Foundation\Console\ConfigShowCommand; use Illuminate\Foundation\Console\ConsoleMakeCommand; +use Illuminate\Foundation\Console\DevCommand; +use Illuminate\Foundation\Console\DevListCommand; use Illuminate\Foundation\Console\DocsCommand; use Illuminate\Foundation\Console\DownCommand; use Illuminate\Foundation\Console\EnumMakeCommand; @@ -91,6 +93,7 @@ use Illuminate\Foundation\Console\ViewCacheCommand; use Illuminate\Foundation\Console\ViewClearCommand; use Illuminate\Foundation\Console\ViewMakeCommand; +use Illuminate\Foundation\DevCommands; use Illuminate\Notifications\Console\NotificationTableCommand; use Illuminate\Queue\Console\BatchesTableCommand; use Illuminate\Queue\Console\ClearCommand as QueueClearCommand; @@ -111,6 +114,7 @@ use Illuminate\Queue\Console\WorkCommand as QueueWorkCommand; use Illuminate\Routing\Console\ControllerMakeCommand; use Illuminate\Routing\Console\MiddlewareMakeCommand; +use Illuminate\Session\Console\SessionGarbageCollectCommand; use Illuminate\Session\Console\SessionTableCommand; use Illuminate\Support\ServiceProvider; @@ -178,6 +182,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ScheduleInterrupt' => ScheduleInterruptCommand::class, 'SchedulePause' => SchedulePauseCommand::class, 'ScheduleResume' => ScheduleResumeCommand::class, + 'SessionGarbageCollect' => SessionGarbageCollectCommand::class, 'ShowModel' => ShowModelCommand::class, 'StorageLink' => StorageLinkCommand::class, 'StorageUnlink' => StorageUnlinkCommand::class, @@ -204,6 +209,8 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ConfigPublish' => ConfigPublishCommand::class, 'ConsoleMake' => ConsoleMakeCommand::class, 'ControllerMake' => ControllerMakeCommand::class, + 'Dev' => DevCommand::class, + 'DevList' => DevListCommand::class, 'Docs' => DocsCommand::class, 'EnumMake' => EnumMakeCommand::class, 'EventGenerate' => EventGenerateCommand::class, @@ -259,6 +266,16 @@ public function register() }); } + /** + * Bootstrap the application services. + * + * @return void + */ + public function boot() + { + DevCommands::registerDefaults(); + } + /** * Register the given commands. * diff --git a/src/Illuminate/Foundation/Providers/ConsoleSupportServiceProvider.php b/src/Illuminate/Foundation/Providers/ConsoleSupportServiceProvider.php index f6131ca5..5e3ac184 100644 --- a/src/Illuminate/Foundation/Providers/ConsoleSupportServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ConsoleSupportServiceProvider.php @@ -11,7 +11,7 @@ class ConsoleSupportServiceProvider extends AggregateServiceProvider implements /** * The provider class names. * - * @var string[] + * @var array> */ protected $providers = [ ArtisanServiceProvider::class, diff --git a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php index 691915ab..3a072be8 100644 --- a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php @@ -41,7 +41,7 @@ class FoundationServiceProvider extends AggregateServiceProvider /** * The provider class names. * - * @var string[] + * @var array> */ protected $providers = [ FormRequestServiceProvider::class, diff --git a/src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php b/src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php index 064c3f09..81fa89e9 100644 --- a/src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php +++ b/src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php @@ -2,6 +2,7 @@ namespace Illuminate\Foundation\Queue; +use Illuminate\Bus\Queueable; use Illuminate\Bus\UniqueLock; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Support\Facades\Context; @@ -20,6 +21,9 @@ public function addUniqueJobInformationToContext($job): void Context::addHidden([ 'laravel_unique_job_cache_store' => $this->getUniqueJobCacheStore($job), 'laravel_unique_job_key' => UniqueLock::getKey($job), + 'laravel_unique_job_lock_owner' => isset(class_uses_recursive($job)[Queueable::class]) + ? ($job->uniqueLockOwner ?? '') + : '', ]); } } @@ -36,6 +40,7 @@ public function removeUniqueJobInformationFromContext($job): void Context::forgetHidden([ 'laravel_unique_job_cache_store', 'laravel_unique_job_key', + 'laravel_unique_job_lock_owner', ]); } } diff --git a/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php b/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php index ac7b3ec7..21602a62 100644 --- a/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php +++ b/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php @@ -90,7 +90,7 @@ public function boot() /** * Get the events and handlers. * - * @return array + * @return array> */ public function listens() { diff --git a/src/Illuminate/Foundation/Testing/Attributes/WithoutMigrationEvents.php b/src/Illuminate/Foundation/Testing/Attributes/WithoutMigrationEvents.php new file mode 100644 index 00000000..2e5ce3d1 --- /dev/null +++ b/src/Illuminate/Foundation/Testing/Attributes/WithoutMigrationEvents.php @@ -0,0 +1,17 @@ +mockConsoleOutput) { - return $this->app[Kernel::class]->call($command, $parameters); - } + return $this->mockConsoleOutput + ? $this->mockArtisan($command, $parameters) + : $this->realArtisan($command, $parameters); + } + /** + * Invoke an Artisan command and return a mocked pending command. + * + * @param string $command + * @param array $parameters + * @return \Illuminate\Testing\PendingCommand + */ + public function mockArtisan($command, $parameters = []): PendingCommand + { return new PendingCommand($this, $this->app, $command, $parameters); } + /** + * Invoke an Artisan command and return the exit code. + * + * @param string $command + * @param array $parameters + * @return int + */ + public function realArtisan($command, $parameters = []): int + { + return $this->app[Kernel::class]->call($command, $parameters); + } + /** * Disable mocking the console output. * diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php index 2ab1d1e2..2b744671 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php @@ -100,13 +100,21 @@ protected function assertDatabaseMissing($table, array $data = [], $connection = /** * Assert the count of table entries. * - * @param \Illuminate\Database\Eloquent\Model|class-string<\Illuminate\Database\Eloquent\Model>|string $table - * @param int $count + * @param iterable|string, int>|\Illuminate\Database\Eloquent\Model|class-string<\Illuminate\Database\Eloquent\Model>|string $table + * @param int|null $count * @param string|null $connection * @return $this */ - protected function assertDatabaseCount($table, int $count, $connection = null) + protected function assertDatabaseCount($table, ?int $count = null, $connection = null) { + if (is_iterable($table)) { + foreach ($table as $name => $expected) { + $this->assertDatabaseCount($name, $expected, $connection); + } + + return $this; + } + $this->assertThat( $this->getTable($table), new CountInDatabase($this->getConnection($connection, $table), $count) ); @@ -115,14 +123,22 @@ protected function assertDatabaseCount($table, int $count, $connection = null) } /** - * Assert that the given table has no entries. + * Assert that the given table or tables has no entries. * - * @param \Illuminate\Database\Eloquent\Model|class-string<\Illuminate\Database\Eloquent\Model>|string $table + * @param iterable<\Illuminate\Database\Eloquent\Model>|\Illuminate\Database\Eloquent\Model|class-string<\Illuminate\Database\Eloquent\Model>|string $table * @param string|null $connection * @return $this */ protected function assertDatabaseEmpty($table, $connection = null) { + if (is_iterable($table)) { + foreach ($table as $item) { + $this->assertDatabaseEmpty($item, $connection); + } + + return $this; + } + $this->assertThat( $this->getTable($table), new CountInDatabase($this->getConnection($connection, $table), 0) ); @@ -143,7 +159,7 @@ protected function assertSoftDeleted($table, array $data = [], $connection = nul { if (is_iterable($table)) { foreach ($table as $item) { - $this->assertSoftDeleted($item, $data, $connection); + $this->assertSoftDeleted($item, $data, $connection, $deletedAtColumn); } return $this; @@ -191,7 +207,7 @@ protected function assertNotSoftDeleted($table, array $data = [], $connection = { if (is_iterable($table)) { foreach ($table as $item) { - $this->assertNotSoftDeleted($item, $data, $connection); + $this->assertNotSoftDeleted($item, $data, $connection, $deletedAtColumn); } return $this; diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php index af1739d1..1ccbf89d 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php @@ -198,17 +198,7 @@ protected function assertThrows(Closure $test, string|Closure $expectedClass = T ); if (isset($expectedMessage)) { - if (! isset($actualMessage)) { - Assert::fail( - sprintf( - 'Failed asserting that exception of type "%s" with message "%s" was thrown.', - $expectedClass, - $expectedMessage - ) - ); - } else { - Assert::assertStringContainsString($expectedMessage, $actualMessage); - } + Assert::assertStringContainsString($expectedMessage, $actualMessage); } return $this; @@ -233,8 +223,8 @@ protected function assertDoesntThrow(Closure $test) $exceptionMessage = $exception->getMessage(); } - Assert::assertTrue( - ! $thrown, + Assert::assertFalse( + $thrown, sprintf('Unexpected exception of type %s with message %s was thrown.', $exceptionClass ?? null, $exceptionMessage ?? null) ); diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithRedis.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithRedis.php index 77b63302..52cbeffd 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithRedis.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithRedis.php @@ -3,7 +3,6 @@ namespace Illuminate\Foundation\Testing\Concerns; use Exception; -use Illuminate\Contracts\Redis\Factory as Redis; use Illuminate\Foundation\Application; use Illuminate\Redis\RedisManager; use Illuminate\Support\Env; diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php index 8a1145c0..549ae672 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php @@ -43,6 +43,7 @@ use Illuminate\Support\Str; use Illuminate\Validation\Validator; use Illuminate\View\Component; +use Laravel\Prompts\Prompt; use Mockery; use Mockery\Exception\InvalidCountException; use PHPUnit\Metadata\Annotation\Parser\Registry as PHPUnitRegistry; @@ -178,6 +179,18 @@ protected function tearDownTheTestEnvironment(): void $this->originalDeprecationHandler = null; } + $this->flushState(); + + if ($this->callbackException) { + throw $this->callbackException; + } + } + + /** + * Reset static state between test executions. + */ + protected function flushState(): void + { AboutCommand::flushState(); Artisan::forgetBootstrappers(); Component::flushCache(); @@ -197,6 +210,7 @@ protected function tearDownTheTestEnvironment(): void Migrator::withoutMigrations([]); Once::flush(); PreventRequestsDuringMaintenance::flushState(); + Prompt::flushState(); Queue::createPayloadUsing(null); RegisterProviders::flushState(); Response::flushState(); @@ -208,10 +222,6 @@ protected function tearDownTheTestEnvironment(): void PreventRequestForgery::flushState(); Validator::flushState(); WorkCommand::flushState(); - - if ($this->callbackException) { - throw $this->callbackException; - } } /** diff --git a/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php b/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php index 0e7b7d16..be7fdd69 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php +++ b/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php @@ -128,7 +128,7 @@ public function withoutHeaders(array $headers) * @param string $type * @return $this */ - public function withToken(string $token, string $type = 'Bearer') + public function withToken(#[\SensitiveParameter] string $token, string $type = 'Bearer') { return $this->withHeader('Authorization', $type.' '.$token); } @@ -140,7 +140,7 @@ public function withToken(string $token, string $type = 'Bearer') * @param string $password * @return $this */ - public function withBasicAuth(string $username, string $password) + public function withBasicAuth(string $username, #[\SensitiveParameter] string $password) { return $this->withToken(base64_encode("$username:$password"), 'Basic'); } @@ -548,6 +548,37 @@ public function head($uri, array $headers = []) return $this->call('HEAD', $uri, [], $cookies, [], $server); } + /** + * Visit the given URI with a QUERY request. + * + * @param \Illuminate\Support\Uri|string $uri + * @param array $data + * @param array $headers + * @return \Illuminate\Testing\TestResponse + */ + public function query($uri, array $data = [], array $headers = []) + { + $server = $this->transformHeadersToServerVars($headers); + + $cookies = $this->prepareCookiesForRequest(); + + return $this->call('QUERY', $uri, $data, $cookies, [], $server); + } + + /** + * Visit the given URI with a QUERY request, expecting a JSON response. + * + * @param \Illuminate\Support\Uri|string $uri + * @param array $data + * @param array $headers + * @param int $options + * @return \Illuminate\Testing\TestResponse + */ + public function queryJson($uri, array $data = [], array $headers = [], $options = 0) + { + return $this->json('QUERY', $uri, $data, $headers, $options); + } + /** * Call the given URI with a JSON request. * diff --git a/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php b/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php index 4fea491d..3cef2862 100644 --- a/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php +++ b/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php @@ -34,7 +34,7 @@ public function addCallback($callback) // If there are no transactions, we'll run the callbacks right away. Also, we'll run it // right away when we're in test mode and we only have the wrapping transaction. For // every other case, we'll queue up the callback to run after the commit happens. - if ($this->callbackApplicableTransactions()->count() === 0) { + if ($this->callbackApplicableTransactions()->isEmpty()) { return $callback(); } diff --git a/src/Illuminate/Foundation/Testing/LazilyRefreshDatabase.php b/src/Illuminate/Foundation/Testing/LazilyRefreshDatabase.php index 194fc3d8..6f785e73 100644 --- a/src/Illuminate/Foundation/Testing/LazilyRefreshDatabase.php +++ b/src/Illuminate/Foundation/Testing/LazilyRefreshDatabase.php @@ -37,8 +37,10 @@ public function refreshDatabase() } }; - $database->beforeStartingTransaction($callback); - $database->beforeExecuting($callback); + foreach ($this->connectionsToTransact() as $connection) { + $database->connection($connection)->beforeStartingTransaction($callback); + $database->connection($connection)->beforeExecuting($callback); + } $this->beforeApplicationDestroyed(function () { RefreshDatabaseState::$lazilyRefreshed = false; diff --git a/src/Illuminate/Foundation/Testing/RefreshDatabase.php b/src/Illuminate/Foundation/Testing/RefreshDatabase.php index 28626ec5..51affd19 100644 --- a/src/Illuminate/Foundation/Testing/RefreshDatabase.php +++ b/src/Illuminate/Foundation/Testing/RefreshDatabase.php @@ -81,7 +81,9 @@ protected function restoreInMemoryDatabase() protected function refreshTestDatabase() { if (! RefreshDatabaseState::$migrated) { - $this->migrateDatabases(); + $this->shouldDispatchMigrationEvents() + ? $this->migrateDatabases() + : $this->migrateDatabasesWithoutEvents(); $this->app[Kernel::class]->setArtisan(null); @@ -119,6 +121,36 @@ protected function migrateDatabases() $this->artisan('migrate:fresh', $this->migrateFreshUsing()); } + /** + * Migrate the database without dispatching database events. + * + * @return void + */ + protected function migrateDatabasesWithoutEvents() + { + $database = $this->app->make('db'); + + $dispatchers = []; + + foreach ($this->connectionsToTransact() as $name) { + $connection = $database->connection($name); + + $dispatchers[$name] = $connection->getEventDispatcher(); + + $connection->unsetEventDispatcher(); + } + + try { + $this->migrateDatabases(); + } finally { + foreach ($dispatchers as $name => $dispatcher) { + if ($dispatcher) { + $database->connection($name)->setEventDispatcher($dispatcher); + } + } + } + } + /** * Begin a database transaction on the testing database. * @@ -159,7 +191,7 @@ public function beginDatabaseTransaction() RefreshDatabaseState::$migrated = false; } - $connection->rollBack(); + $connection->rollBack(0); $connection->setEventDispatcher($dispatcher); $connection->disconnect(); } diff --git a/src/Illuminate/Foundation/Testing/Traits/CanConfigureMigrationCommands.php b/src/Illuminate/Foundation/Testing/Traits/CanConfigureMigrationCommands.php index 72b17a77..04b1f6f3 100644 --- a/src/Illuminate/Foundation/Testing/Traits/CanConfigureMigrationCommands.php +++ b/src/Illuminate/Foundation/Testing/Traits/CanConfigureMigrationCommands.php @@ -4,7 +4,10 @@ use Illuminate\Foundation\Testing\Attributes\Seed; use Illuminate\Foundation\Testing\Attributes\Seeder; +use Illuminate\Foundation\Testing\Attributes\WithoutMigrationEvents; use ReflectionClass; +use ReflectionMethod; +use Throwable; trait CanConfigureMigrationCommands { @@ -64,6 +67,32 @@ protected function shouldSeed() return property_exists($this, 'seed') ? $this->seed : false; } + /** + * Determine if database events should be dispatched while migrating the database. + * + * @return bool + */ + protected function shouldDispatchMigrationEvents() + { + try { + if ((new ReflectionMethod(static::class, $this->name()))->getAttributes(WithoutMigrationEvents::class) !== []) { + return false; + } + } catch (Throwable) { + // + } + + $class = new ReflectionClass($this); + + do { + if ($class->getAttributes(WithoutMigrationEvents::class) !== []) { + return false; + } + } while ($class = $class->getParentClass()); + + return true; + } + /** * Determine the specific seeder class that should be used when refreshing the database. * diff --git a/src/Illuminate/Foundation/Vite.php b/src/Illuminate/Foundation/Vite.php index 90086436..f0fb4480 100644 --- a/src/Illuminate/Foundation/Vite.php +++ b/src/Illuminate/Foundation/Vite.php @@ -873,7 +873,21 @@ public function reactRefresh() */ protected function hotAsset($asset) { - return rtrim(file_get_contents($this->hotFile())).'/'.$asset; + return $this->devServerUrl().'/'.$asset; + } + + /** + * Get the URL of the running Vite development server. + * + * @return string|null + */ + public function devServerUrl() + { + if (! $this->isRunningHot()) { + return null; + } + + return rtrim(file_get_contents($this->hotFile())); } /** diff --git a/src/Illuminate/Foundation/helpers.php b/src/Illuminate/Foundation/helpers.php index ece3f6fa..37c09905 100644 --- a/src/Illuminate/Foundation/helpers.php +++ b/src/Illuminate/Foundation/helpers.php @@ -1018,6 +1018,7 @@ function trans_choice($key, $number, array $replace = [], $locale = null): strin * @param string|null $key * @param array $replace * @param string|null $locale + * @return ($key is null ? null : array|string) */ function __($key = null, $replace = [], $locale = null): string|array|null { diff --git a/src/Illuminate/Foundation/resources/exceptions/renderer/components/formatted-source.blade.php b/src/Illuminate/Foundation/resources/exceptions/renderer/components/formatted-source.blade.php index 3a5160e7..e0a2811f 100644 --- a/src/Illuminate/Foundation/resources/exceptions/renderer/components/formatted-source.blade.php +++ b/src/Illuminate/Foundation/resources/exceptions/renderer/components/formatted-source.blade.php @@ -19,5 +19,5 @@ language="php" truncate class="text-xs min-w-0" - data-tippy-content="{{ $source }}" + data-tippy-html-content="{{ $source }}" /> diff --git a/src/Illuminate/Foundation/resources/exceptions/renderer/dist/scripts.js b/src/Illuminate/Foundation/resources/exceptions/renderer/dist/scripts.js index 7569eff7..f0fce2ca 100644 --- a/src/Illuminate/Foundation/resources/exceptions/renderer/dist/scripts.js +++ b/src/Illuminate/Foundation/resources/exceptions/renderer/dist/scripts.js @@ -1,21 +1,21 @@ -var lr=!1,ur=!1,ot=[],pr=-1,Wr=!1;function Ql(e){tu(e)}function Jl(){Wr=!0}function eu(){Wr=!1,io()}function tu(e){ot.includes(e)||ot.push(e),io()}function nu(e){let t=ot.indexOf(e);t!==-1&&t>pr&&ot.splice(t,1)}function io(){if(!ur&&!lr){if(Wr)return;lr=!0,queueMicrotask(au)}}function au(){lr=!1,ur=!0;for(let e=0;ee.effect(t,{scheduler:n=>{dr?Ql(n):n()}}),so=e.raw}function Mi(e){_t=e}function su(e){let t=()=>{};return[a=>{let r=_t(a);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(i=>i())}),e._x_effects.add(r),t=()=>{r!==void 0&&(e._x_effects.delete(r),Bt(r))},r},()=>{t()}]}function oo(e,t){let n=!0,a,r=_t(()=>{let i=e();if(JSON.stringify(i),!n&&(typeof i=="object"||i!==a)){let s=a;queueMicrotask(()=>{t(i,s)})}a=i,n=!1});return()=>Bt(r)}async function ou(e){Jl();try{await e(),await Promise.resolve()}finally{eu()}}var co=[],lo=[],uo=[];function cu(e){uo.push(e)}function Vr(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,lo.push(t))}function po(e){co.push(e)}function mo(e,t,n){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(n)}function ho(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([n,a])=>{(t===void 0||t.includes(n))&&(a.forEach(r=>r()),delete e._x_attributeCleanups[n])})}function lu(e){for(e._x_effects?.forEach(nu);e._x_cleanups?.length;)e._x_cleanups.pop()()}var Zr=new MutationObserver(Qr),Yr=!1;function Xr(){Zr.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),Yr=!0}function go(){uu(),Zr.disconnect(),Yr=!1}var tn=[];function uu(){let e=Zr.takeRecords();tn.push(()=>e.length>0&&Qr(e));let t=tn.length;queueMicrotask(()=>{if(tn.length===t)for(;tn.length>0;)tn.shift()()})}function z(e){if(!Yr)return e();go();let t=e();return Xr(),t}var Kr=!1,la=[];function pu(){Kr=!0}function du(){Kr=!1,Qr(la),la=[]}function Qr(e){if(Kr){la=la.concat(e);return}let t=[],n=new Set,a=new Map,r=new Map;for(let i=0;i{s.nodeType===1&&s._x_marker&&n.add(s)}),e[i].addedNodes.forEach(s=>{if(s.nodeType===1){if(n.has(s)){n.delete(s);return}s._x_marker||t.push(s)}})),e[i].type==="attributes")){let s=e[i].target,o=e[i].attributeName,c=e[i].oldValue,l=()=>{a.has(s)||a.set(s,[]),a.get(s).push({name:o,value:s.getAttribute(o)})},u=()=>{r.has(s)||r.set(s,[]),r.get(s).push(o)};s.hasAttribute(o)&&c===null?l():s.hasAttribute(o)?(u(),l()):u()}r.forEach((i,s)=>{ho(s,i)}),a.forEach((i,s)=>{co.forEach(o=>o(s,i))});for(let i of n)t.some(s=>s.contains(i))||lo.forEach(s=>s(i));for(let i of t)i.isConnected&&uo.forEach(s=>s(i));t=null,n=null,a=null,r=null}function fo(e){return mt(dt(e))}function Fn(e,t,n){return e._x_dataStack=[t,...dt(n||e)],()=>{e._x_dataStack=e._x_dataStack.filter(a=>a!==t)}}function dt(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?dt(e.host):e.parentNode?dt(e.parentNode):[]}function mt(e){return new Proxy({objects:e},mu)}var mu={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(n=>Object.prototype.hasOwnProperty.call(n,t)||Reflect.has(n,t))},get({objects:e},t,n){return t=="toJSON"?hu:Reflect.get(e.find(a=>Reflect.has(a,t))||{},t,n)},set({objects:e},t,n,a){const r=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],i=Object.getOwnPropertyDescriptor(r,t);return i?.set&&i?.get?i.set.call(a,n)||!0:Reflect.set(r,t,n)}};function hu(){return Reflect.ownKeys(this).reduce((t,n)=>(t[n]=Reflect.get(this,n),t),{})}function Jr(e){let t=a=>typeof a=="object"&&!Array.isArray(a)&&a!==null,n=(a,r="")=>{Object.entries(Object.getOwnPropertyDescriptors(a)).forEach(([i,{value:s,enumerable:o}])=>{if(o===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=r===""?i:`${r}.${i}`;typeof s=="object"&&s!==null&&s._x_interceptor?a[i]=s.initialize(e,c,i):t(s)&&s!==a&&!(s instanceof Element)&&n(s,c)})};return n(e)}function bo(e,t=()=>{}){let n={initialValue:void 0,_x_interceptor:!0,initialize(a,r,i){return e(this.initialValue,()=>gu(a,r),s=>mr(a,r,s),r,i)}};return t(n),a=>{if(typeof a=="object"&&a!==null&&a._x_interceptor){let r=n.initialize.bind(n);n.initialize=(i,s,o)=>{let c=a.initialize(i,s,o);return n.initialValue=c,r(i,s,o)}}else n.initialValue=a;return n}}function gu(e,t){return t.split(".").reduce((n,a)=>n[a],e)}function mr(e,t,n){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=n;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),mr(e[t[0]],t.slice(1),n)}}var _o={};function ve(e,t){_o[e]=t}function fn(e,t){let n=fu(t);return Object.entries(_o).forEach(([a,r])=>{Object.defineProperty(e,`$${a}`,{get(){return r(t,n)},enumerable:!1})}),e}function fu(e){let[t,n]=Fo(e),a={interceptor:bo,...t};return Vr(e,n),a}function bu(e,t,n,...a){try{return n(...a)}catch(r){bn(r,e,t)}}function bn(...e){return yo(...e)}var yo=yu;function _u(e){yo=e}function yu(e,t,n=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:n}),console.warn(`Alpine Expression Error: ${e.message} +var e=Object.defineProperty,t=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:`Module`}),r},n=!1,r=!1,i=[],a=-1,o=!1;function s(e){u(e)}function c(){o=!0}function l(){o=!1,f()}function u(e){i.includes(e)||i.push(e),f()}function d(e){let t=i.indexOf(e);t!==-1&&t>a&&i.splice(t,1)}function f(){if(!r&&!n){if(o)return;n=!0,queueMicrotask(p)}}function p(){n=!1,r=!0;for(let e=0;ee.effect(t,{scheduler:e=>{v?s(e):e()}}),_=e.raw}function x(e){h=e}function S(e){let t=()=>{};return[n=>{let r=h(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(e=>e())}),e._x_effects.add(r),t=()=>{r!==void 0&&(e._x_effects.delete(r),g(r))},r},()=>{t()}]}function C(e,t){let n=!0,r,i=h(()=>{let i=e();if(JSON.stringify(i),!n&&(typeof i==`object`||i!==r)){let e=r;queueMicrotask(()=>{t(i,e)})}r=i,n=!1});return()=>g(i)}async function w(e){c();try{await e(),await Promise.resolve()}finally{l()}}var T=[],E=[],D=[];function O(e){D.push(e)}function k(e,t){typeof t==`function`?(e._x_cleanups||=[],e._x_cleanups.push(t)):(t=e,E.push(t))}function ee(e){T.push(e)}function A(e,t,n){e._x_attributeCleanups||={},e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(n)}function j(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([n,r])=>{(t===void 0||t.includes(n))&&(r.forEach(e=>e()),delete e._x_attributeCleanups[n])})}function M(e){for(e._x_effects?.forEach(d);e._x_cleanups?.length;)e._x_cleanups.pop()()}var te=new MutationObserver(de),ne=!1;function re(){te.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ne=!0}function ie(){oe(),te.disconnect(),ne=!1}var ae=[];function oe(){let e=te.takeRecords();ae.push(()=>e.length>0&&de(e));let t=ae.length;queueMicrotask(()=>{if(ae.length===t)for(;ae.length>0;)ae.shift()()})}function N(e){if(!ne)return e();ie();let t=e();return re(),t}var se=!1,ce=[];function le(){se=!0}function ue(){se=!1,de(ce),ce=[]}function de(e){if(se){ce=ce.concat(e);return}let t=[],n=new Set,r=new Map,i=new Map;for(let a=0;a{e.nodeType===1&&e._x_marker&&n.add(e)}),e[a].addedNodes.forEach(e=>{e.nodeType===1&&(n.has(e)?n.delete(e):e._x_marker||t.push(e))})),e[a].type===`attributes`)){let t=e[a].target,n=e[a].attributeName,o=e[a].oldValue,s=()=>{r.has(t)||r.set(t,[]),r.get(t).push({name:n,value:t.getAttribute(n)})},c=()=>{i.has(t)||i.set(t,[]),i.get(t).push(n)};t.hasAttribute(n)&&o===null?s():t.hasAttribute(n)?(c(),s()):c()}i.forEach((e,t)=>{j(t,e)}),r.forEach((e,t)=>{T.forEach(n=>n(t,e))});for(let e of n)t.some(t=>t.contains(e))||E.forEach(t=>t(e));for(let e of t)e.isConnected&&D.forEach(t=>t(e));t=null,n=null,r=null,i=null}function fe(e){return he(me(e))}function pe(e,t,n){return e._x_dataStack=[t,...me(n||e)],()=>{e._x_dataStack=e._x_dataStack.filter(e=>e!==t)}}function me(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot==`function`&&e instanceof ShadowRoot?me(e.host):e.parentNode?me(e.parentNode):[]}function he(e){return new Proxy({objects:e},ge)}var ge={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(e=>Object.keys(e))))},has({objects:e},t){return t!=Symbol.unscopables&&e.some(e=>Object.prototype.hasOwnProperty.call(e,t)||Reflect.has(e,t))},get({objects:e},t,n){return t==`toJSON`?_e:Reflect.get(e.find(e=>Reflect.has(e,t))||{},t,n)},set({objects:e},t,n,r){let i=e.find(e=>Object.prototype.hasOwnProperty.call(e,t))||e[e.length-1],a=Object.getOwnPropertyDescriptor(i,t);return a?.set&&a?.get?a.set.call(r,n)||!0:Reflect.set(i,t,n)}};function _e(){return Reflect.ownKeys(this).reduce((e,t)=>(e[t]=Reflect.get(this,t),e),{})}function ve(e){let t=e=>typeof e==`object`&&!Array.isArray(e)&&e!==null,n=(r,i=``)=>{Object.entries(Object.getOwnPropertyDescriptors(r)).forEach(([a,{value:o,enumerable:s}])=>{if(s===!1||o===void 0||typeof o==`object`&&o&&o.__v_skip)return;let c=i===``?a:`${i}.${a}`;typeof o==`object`&&o&&o._x_interceptor?r[a]=o.initialize(e,c,a):t(o)&&o!==r&&!(o instanceof Element)&&n(o,c)})};return n(e)}function ye(e,t=()=>{}){let n={initialValue:void 0,_x_interceptor:!0,initialize(t,n,r){return e(this.initialValue,()=>be(t,n),e=>xe(t,n,e),n,r)}};return t(n),e=>{if(typeof e==`object`&&e&&e._x_interceptor){let t=n.initialize.bind(n);n.initialize=(r,i,a)=>{let o=e.initialize(r,i,a);return n.initialValue=o,t(r,i,a)}}else n.initialValue=e;return n}}function be(e,t){return t.split(`.`).reduce((e,t)=>e[t],e)}function xe(e,t,n){if(typeof t==`string`&&(t=t.split(`.`)),t.length===1)e[t[0]]=n;else if(t.length===0)throw error;else if(e[t[0]])return xe(e[t[0]],t.slice(1),n);else return e[t[0]]={},xe(e[t[0]],t.slice(1),n)}var Se={};function P(e,t){Se[e]=t}function Ce(e,t){let n=we(t);return Object.entries(Se).forEach(([r,i])=>{Object.defineProperty(e,`$${r}`,{get(){return i(t,n)},enumerable:!1})}),e}function we(e){let[t,n]=nt(e),r={interceptor:ye,...t};return k(e,n),r}function Te(e,t,n,...r){try{return n(...r)}catch(n){Ee(n,e,t)}}function Ee(...e){return De(...e)}var De=ke;function Oe(e){De=e}function ke(e,t,n=void 0){e=Object.assign(e??{message:`No error message given.`},{el:t,expression:n}),console.warn(`Alpine Expression Error: ${e.message} -${n?'Expression: "'+n+`" +${n?`Expression: "`+n+`" -`:""}`,t),setTimeout(()=>{throw e},0)}var Rt=!0;function vo(e){let t=Rt;Rt=!1;let n=e();return Rt=t,n}function ct(e,t,n={}){let a;return ne(e,t)(r=>a=r,n),a}function ne(...e){return wo(...e)}var wo=ko;function vu(e){wo=e}var xo;function wu(e){xo=e}function ko(e,t){let n={};fn(n,e);let a=[n,...dt(e)],r=typeof t=="function"?xu(a,t):Cu(a,t,e);return bu.bind(null,e,t,r)}function xu(e,t){return(n=()=>{},{scope:a={},params:r=[],context:i}={})=>{if(!Rt){_n(n,t,mt([a,...e]),r);return}let s=t.apply(mt([a,...e]),r);_n(n,s)}}var za={};function ku(e,t){if(za[e])return za[e];let n=Object.getPrototypeOf(async function(){}).constructor,a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,i=(()=>{try{let s=new n(["__self","scope"],`with (scope) { __self.result = ${a} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return bn(s,t,e),Promise.resolve()}})();return za[e]=i,i}function Cu(e,t,n){let a=ku(t,n);return(r=()=>{},{scope:i={},params:s=[],context:o}={})=>{a.result=void 0,a.finished=!1;let c=mt([i,...e]);if(typeof a=="function"){let l=a.call(o,a,c).catch(u=>bn(u,n,t));a.finished?(_n(r,a.result,c,s,n),a.result=void 0):l.then(u=>{_n(r,u,c,s,n)}).catch(u=>bn(u,n,t)).finally(()=>a.result=void 0)}}}function _n(e,t,n,a,r){if(Rt&&typeof t=="function"){let i=t.apply(n,a);i instanceof Promise?i.then(s=>_n(e,s,n,a)).catch(s=>bn(s,r,t)):e(i)}else typeof t=="object"&&t instanceof Promise?t.then(i=>e(i)):e(t)}function Eu(...e){return xo(...e)}function Fu(e,t,n={}){let a={};fn(a,e);let r=[a,...dt(e)],i=mt([n.scope??{},...r]),s=n.params??[];if(t.includes("await")){let o=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t;return new o(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(n.context,i)}else{let o=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(()=>{ ${t} })()`:t,l=new Function(["scope"],`with (scope) { let __result = ${o}; return __result }`).call(n.context,i);return typeof l=="function"&&Rt?l.apply(i,s):l}}var ei="x-";function Ut(e=""){return ei+e}function $u(e){ei=e}var ua={};function W(e,t){return ua[e]=t,{before(n){if(!ua[n]){console.warn(String.raw`Cannot find directive \`${n}\`. \`${e}\` will use the default order of execution`);return}const a=rt.indexOf(n);rt.splice(a>=0?a:rt.indexOf("DEFAULT"),0,e)}}}function ju(e){return Object.keys(ua).includes(e)}function ti(e,t,n){if(t=Array.from(t),e._x_virtualDirectives){let i=Object.entries(e._x_virtualDirectives).map(([o,c])=>({name:o,value:c})),s=Co(i);i=i.map(o=>s.find(c=>c.name===o.name)?{name:`x-bind:${o.name}`,value:`"${o.value}"`}:o),t=t.concat(i)}let a={};return t.map(So((i,s)=>a[i]=s)).filter(To).map(Tu(a,n)).sort(Ru).map(i=>Au(e,i))}function Co(e){return Array.from(e).map(So()).filter(t=>!To(t))}var hr=!1,on=new Map,Eo=Symbol();function Su(e){hr=!0;let t=Symbol();Eo=t,on.set(t,[]);let n=()=>{for(;on.get(t).length;)on.get(t).shift()();on.delete(t)},a=()=>{hr=!1,n()};e(n),a()}function Fo(e){let t=[],n=o=>t.push(o),[a,r]=su(e);return t.push(r),[{Alpine:Ht,effect:a,cleanup:n,evaluateLater:ne.bind(ne,e),evaluate:ct.bind(ct,e)},()=>t.forEach(o=>o())]}function Au(e,t){let n=()=>{},a=ua[t.type]||n,[r,i]=Fo(e);mo(e,t.original,i);let s=()=>{e._x_ignore||e._x_ignoreSelf||(a.inline&&a.inline(e,t,r),a=a.bind(a,e,t,r),hr?on.get(Eo).push(a):a())};return s.runCleanups=i,s}var $o=(e,t)=>({name:n,value:a})=>(n.startsWith(e)&&(n=n.replace(e,t)),{name:n,value:a}),jo=e=>e;function So(e=()=>{}){return({name:t,value:n})=>{let{name:a,value:r}=Ao.reduce((i,s)=>s(i),{name:t,value:n});return a!==t&&e(a,t),{name:a,value:r}}}var Ao=[];function ni(e){Ao.push(e)}function To({name:e}){return Ro().test(e)}var Ro=()=>new RegExp(`^${ei}([^:^.]+)\\b`);function Tu(e,t){return({name:n,value:a})=>{n===a&&(a="");let r=n.match(Ro()),i=n.match(/:([a-zA-Z0-9\-_:]+)/),s=n.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],o=t||e[n]||n;return{type:r?r[1]:null,value:i?i[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:a,original:o}}}var gr="DEFAULT",rt=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",gr,"teleport"];function Ru(e,t){let n=rt.indexOf(e.type)===-1?gr:e.type,a=rt.indexOf(t.type)===-1?gr:t.type;return rt.indexOf(n)-rt.indexOf(a)}function ln(e,t,n={}){e.dispatchEvent(new CustomEvent(t,{detail:n,bubbles:!0,composed:!0,cancelable:!0}))}function ht(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(r=>ht(r,t));return}let n=!1;if(t(e,()=>n=!0),n)return;let a=e.firstElementChild;for(;a;)ht(a,t),a=a.nextElementSibling}function pe(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var qi=!1;function Ou(){qi&&pe("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),qi=!0,document.body||pe("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `