first commit

This commit is contained in:
Sampanna Rimal
2024-08-27 17:48:06 +05:45
commit 53c0140f58
10839 changed files with 1125847 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
<?php
namespace Spatie\LaravelIgnition\ArgumentReducers;
use Illuminate\Support\Collection;
use Spatie\Backtrace\Arguments\ReducedArgument\ReducedArgumentContract;
use Spatie\Backtrace\Arguments\ReducedArgument\UnReducedArgument;
use Spatie\Backtrace\Arguments\Reducers\ArrayArgumentReducer;
class CollectionArgumentReducer extends ArrayArgumentReducer
{
public function execute(mixed $argument): ReducedArgumentContract
{
if (! $argument instanceof Collection) {
return UnReducedArgument::create();
}
return $this->reduceArgument($argument->toArray(), get_class($argument));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Spatie\LaravelIgnition\ArgumentReducers;
use Illuminate\Database\Eloquent\Model;
use Spatie\Backtrace\Arguments\ReducedArgument\ReducedArgument;
use Spatie\Backtrace\Arguments\ReducedArgument\ReducedArgumentContract;
use Spatie\Backtrace\Arguments\ReducedArgument\UnReducedArgument;
use Spatie\Backtrace\Arguments\Reducers\ArgumentReducer;
class ModelArgumentReducer implements ArgumentReducer
{
public function execute(mixed $argument): ReducedArgumentContract
{
if (! $argument instanceof Model) {
return UnReducedArgument::create();
}
return new ReducedArgument(
"{$argument->getKeyName()}:{$argument->getKey()}",
get_class($argument)
);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Spatie\LaravelIgnition\Commands;
use Illuminate\Console\GeneratorCommand;
use Symfony\Component\Console\Input\InputOption;
class SolutionMakeCommand extends GeneratorCommand
{
protected $name = 'ignition:make-solution';
protected $description = 'Create a new custom Ignition solution class';
protected $type = 'Solution';
protected function getStub(): string
{
return $this->option('runnable')
? __DIR__.'/stubs/runnable-solution.stub'
: __DIR__.'/stubs/solution.stub';
}
protected function getDefaultNamespace($rootNamespace)
{
return "{$rootNamespace}\\Solutions";
}
/** @return array<int, mixed> */
protected function getOptions(): array
{
return [
['runnable', null, InputOption::VALUE_NONE, 'Create runnable solution'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Spatie\LaravelIgnition\Commands;
use Illuminate\Console\GeneratorCommand;
class SolutionProviderMakeCommand extends GeneratorCommand
{
protected $name = 'ignition:make-solution-provider';
protected $description = 'Create a new custom Ignition solution provider class';
protected $type = 'Solution Provider';
protected function getStub(): string
{
return __DIR__.'/stubs/solution-provider.stub';
}
protected function getDefaultNamespace($rootNamespace)
{
return "{$rootNamespace}\\SolutionProviders";
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace Spatie\LaravelIgnition\Commands;
use Composer\InstalledVersions;
use Exception;
use Illuminate\Config\Repository;
use Illuminate\Console\Command;
use Illuminate\Log\LogManager;
use Spatie\FlareClient\Flare;
use Spatie\FlareClient\Http\Exceptions\BadResponseCode;
class TestCommand extends Command
{
protected $signature = 'flare:test';
protected $description = 'Send a test notification to Flare';
protected Repository $config;
public function handle(Repository $config): void
{
$this->config = $config;
$this->checkFlareKey();
if (app()->make('log') instanceof LogManager) {
$this->checkFlareLogger();
}
$this->sendTestException();
}
protected function checkFlareKey(): self
{
$message = empty($this->config->get('flare.key'))
? '❌ Flare key not specified. Make sure you specify a value in the `key` key of the `flare` config file.'
: '✅ Flare key specified';
$this->info($message);
return $this;
}
public function checkFlareLogger(): self
{
$defaultLogChannel = $this->config->get('logging.default');
$activeStack = $this->config->get("logging.channels.{$defaultLogChannel}");
if (is_null($activeStack)) {
$this->info("❌ The default logging channel `{$defaultLogChannel}` is not configured in the `logging` config file");
}
if (! isset($activeStack['channels']) || ! in_array('flare', $activeStack['channels'])) {
$this->info("❌ The logging channel `{$defaultLogChannel}` does not contain the 'flare' channel");
}
if (is_null($this->config->get('logging.channels.flare'))) {
$this->info('❌ There is no logging channel named `flare` in the `logging` config file');
}
if ($this->config->get('logging.channels.flare.driver') !== 'flare') {
$this->info('❌ The `flare` logging channel defined in the `logging` config file is not set to `flare`.');
}
if ($this->config->get('ignition.with_stack_frame_arguments') && ini_get('zend.exception_ignore_args')) {
$this->info('⚠️ The `zend.exception_ignore_args` php ini setting is enabled. This will prevent Flare from showing stack trace arguments.');
}
$this->info('✅ The Flare logging driver was configured correctly.');
return $this;
}
protected function sendTestException(): void
{
$testException = new Exception('This is an exception to test if the integration with Flare works.');
try {
app(Flare::class)->sendTestReport($testException);
$this->info('');
} catch (Exception $exception) {
$this->warn('❌ We were unable to send an exception to Flare. ');
if ($exception instanceof BadResponseCode) {
$this->info('');
$message = 'Unknown error';
$body = $exception->response->getBody();
if (is_array($body) && isset($body['message'])) {
$message = $body['message'];
}
$this->warn("{$exception->response->getHttpResponseCode()} - {$message}");
} else {
$this->warn($exception->getMessage());
}
$this->warn('Make sure that your key is correct and that you have a valid subscription.');
$this->info('');
$this->info('For more info visit the docs on https://flareapp.io/docs/ignition-for-laravel/introduction');
$this->info('You can see the status page of Flare at https://status.flareapp.io');
$this->info('Flare support can be reached at support@flareapp.io');
$this->line('');
$this->line('Extra info');
$this->table([], [
['Platform', PHP_OS],
['PHP', phpversion()],
['Laravel', app()->version()],
['spatie/ignition', InstalledVersions::getVersion('spatie/ignition')],
['spatie/laravel-ignition', InstalledVersions::getVersion('spatie/laravel-ignition')],
['spatie/flare-client-php', InstalledVersions::getVersion('spatie/flare-client-php')],
/** @phpstan-ignore-next-line */
['Curl', curl_version()['version'] ?? 'Unknown'],
/** @phpstan-ignore-next-line */
['SSL', curl_version()['ssl_version'] ?? 'Unknown'],
]);
if ($this->output->isVerbose()) {
throw $exception;
}
return;
}
$this->info('We tried to send an exception to Flare. Please check if it arrived!');
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace DummyNamespace;
use Spatie\Ignition\Contracts\RunnableSolution;
class DummyClass implements RunnableSolution
{
public function getSolutionTitle(): string
{
return '';
}
public function getDocumentationLinks(): array
{
return [];
}
public function getSolutionActionDescription(): string
{
return '';
}
public function getRunButtonText(): string
{
return '';
}
public function getSolutionDescription(): string
{
return '';
}
public function getRunParameters(): array
{
return [];
}
public function run(array $parameters = [])
{
//
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace DummyNamespace;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Throwable;
class DummyClass implements HasSolutionsForThrowable
{
public function canSolve(Throwable $throwable): bool
{
return false;
}
public function getSolutions(Throwable $throwable): array
{
return [];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace DummyNamespace;
use Spatie\Ignition\Contracts\Solution;
class DummyClass implements Solution
{
public function getSolutionTitle(): string
{
return '';
}
public function getSolutionDescription(): string
{
return '';
}
public function getDocumentationLinks(): array
{
return [];
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Spatie\LaravelIgnition\ContextProviders;
use Spatie\FlareClient\Context\ConsoleContextProvider;
class LaravelConsoleContextProvider extends ConsoleContextProvider
{
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Spatie\LaravelIgnition\ContextProviders;
use Illuminate\Http\Request;
use Livewire\LivewireManager;
use Spatie\FlareClient\Context\ContextProvider;
use Spatie\FlareClient\Context\ContextProviderDetector;
class LaravelContextProviderDetector implements ContextProviderDetector
{
public function detectCurrentContext(): ContextProvider
{
if (app()->runningInConsole()) {
return new LaravelConsoleContextProvider($_SERVER['argv'] ?? []);
}
$request = app(Request::class);
if ($this->isRunningLiveWire($request)) {
return new LaravelLivewireRequestContextProvider($request, app(LivewireManager::class));
}
return new LaravelRequestContextProvider($request);
}
protected function isRunningLiveWire(Request $request): bool
{
return $request->hasHeader('x-livewire') && $request->hasHeader('referer');
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Spatie\LaravelIgnition\ContextProviders;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Livewire\LivewireManager;
class LaravelLivewireRequestContextProvider extends LaravelRequestContextProvider
{
public function __construct(
Request $request,
protected LivewireManager $livewireManager
) {
parent::__construct($request);
}
/** @return array<string, string> */
public function getRequest(): array
{
$properties = parent::getRequest();
$properties['method'] = $this->livewireManager->originalMethod();
$properties['url'] = $this->livewireManager->originalUrl();
return $properties;
}
/** @return array<int|string, mixed> */
public function toArray(): array
{
$properties = parent::toArray();
$properties['livewire'] = $this->getLivewireInformation();
return $properties;
}
/** @return array<string, mixed> */
protected function getLivewireInformation(): array
{
/** @phpstan-ignore-next-line */
$componentId = $this->request->input('fingerprint.id');
/** @phpstan-ignore-next-line */
$componentAlias = $this->request->input('fingerprint.name');
if ($componentAlias === null) {
return [];
}
try {
$componentClass = $this->livewireManager->getClass($componentAlias);
} catch (Exception $e) {
$componentClass = null;
}
return [
'component_class' => $componentClass,
'component_alias' => $componentAlias,
'component_id' => $componentId,
'data' => $this->resolveData(),
'updates' => $this->resolveUpdates(),
];
}
/** @return array<string, mixed> */
protected function resolveData(): array
{
/** @phpstan-ignore-next-line */
$data = $this->request->input('serverMemo.data') ?? [];
/** @phpstan-ignore-next-line */
$dataMeta = $this->request->input('serverMemo.dataMeta') ?? [];
foreach ($dataMeta['modelCollections'] ?? [] as $key => $value) {
$data[$key] = array_merge($data[$key] ?? [], $value);
}
foreach ($dataMeta['models'] ?? [] as $key => $value) {
$data[$key] = array_merge($data[$key] ?? [], $value);
}
return $data;
}
/** @return array<string, mixed> */
protected function resolveUpdates(): array
{
/** @phpstan-ignore-next-line */
$updates = $this->request->input('updates') ?? [];
return array_map(function (array $update) {
$update['payload'] = Arr::except($update['payload'] ?? [], ['id']);
return $update;
}, $updates);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace Spatie\LaravelIgnition\ContextProviders;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request as LaravelRequest;
use Spatie\FlareClient\Context\RequestContextProvider;
use Symfony\Component\HttpFoundation\Request as SymphonyRequest;
use Throwable;
class LaravelRequestContextProvider extends RequestContextProvider
{
protected LaravelRequest|SymphonyRequest|null $request;
public function __construct(LaravelRequest $request)
{
$this->request = $request;
}
/** @return null|array<string, mixed> */
public function getUser(): array|null
{
try {
/** @var object|null $user */
/** @phpstan-ignore-next-line */
$user = $this->request?->user();
if (! $user) {
return null;
}
} catch (Throwable) {
return null;
}
try {
if (method_exists($user, 'toFlare')) {
return $user->toFlare();
}
if (method_exists($user, 'toArray')) {
return $user->toArray();
}
} catch (Throwable $e) {
return null;
}
return null;
}
/** @return null|array<string, mixed> */
public function getRoute(): array|null
{
/**
* @phpstan-ignore-next-line
* @var \Illuminate\Routing\Route|null $route
*/
$route = $this->request->route();
if (! $route) {
return null;
}
return [
'route' => $route->getName(),
'routeParameters' => $this->getRouteParameters(),
'controllerAction' => $route->getActionName(),
'middleware' => array_values($route->gatherMiddleware() ?? []),
];
}
/** @return array<int, mixed> */
protected function getRouteParameters(): array
{
try {
/** @phpstan-ignore-next-line */
return collect(optional($this->request->route())->parameters ?? [])
->map(fn ($parameter) => $parameter instanceof Model ? $parameter->withoutRelations() : $parameter)
->map(function ($parameter) {
return method_exists($parameter, 'toFlare') ? $parameter->toFlare() : $parameter;
})
->toArray();
} catch (Throwable) {
return [];
}
}
/** @return array<int, mixed> */
public function toArray(): array
{
$properties = parent::toArray();
if ($route = $this->getRoute()) {
$properties['route'] = $route;
}
if ($user = $this->getUser()) {
$properties['user'] = $user;
}
return $properties;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Spatie\LaravelIgnition\Exceptions;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\ProvidesSolution;
use Spatie\Ignition\Contracts\Solution;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CannotExecuteSolutionForNonLocalIp extends HttpException implements ProvidesSolution
{
public static function make(): self
{
return new self(403, 'Solutions cannot be run from your current IP address.');
}
public function getSolution(): Solution
{
return BaseSolution::create()
->setSolutionTitle('Checking your environment settings')
->setSolutionDescription("Solutions can only be executed by requests from a local IP address. Keep in mind that `APP_DEBUG` should set to false on any production environment.");
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Spatie\LaravelIgnition\Exceptions;
use Exception;
use Monolog\Level;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\ProvidesSolution;
use Spatie\Ignition\Contracts\Solution;
class InvalidConfig extends Exception implements ProvidesSolution
{
public static function invalidLogLevel(string $logLevel): self
{
return new self("Invalid log level `{$logLevel}` specified.");
}
public function getSolution(): Solution
{
$validLogLevels = array_map(
fn (string $level) => strtolower($level),
array_keys(Level::VALUES)
);
$validLogLevelsString = implode(',', $validLogLevels);
return BaseSolution::create()
->setSolutionTitle('You provided an invalid log level')
->setSolutionDescription("Please change the log level in your `config/logging.php` file. Valid log levels are {$validLogLevelsString}.");
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Spatie\LaravelIgnition\Exceptions;
use ErrorException;
use Spatie\FlareClient\Contracts\ProvidesFlareContext;
use Spatie\LaravelIgnition\Recorders\DumpRecorder\HtmlDumper;
class ViewException extends ErrorException implements ProvidesFlareContext
{
/** @var array<string, mixed> */
protected array $viewData = [];
protected string $view = '';
/**
* @param array<string, mixed> $data
*
* @return void
*/
public function setViewData(array $data): void
{
$this->viewData = $data;
}
/** @return array<string, mixed> */
public function getViewData(): array
{
return $this->viewData;
}
public function setView(string $path): void
{
$this->view = $path;
}
protected function dumpViewData(mixed $variable): string
{
return (new HtmlDumper())->dumpVariable($variable);
}
/** @return array<string, mixed> */
public function context(): array
{
$context = [
'view' => [
'view' => $this->view,
],
];
$context['view']['data'] = array_map([$this, 'dumpViewData'], $this->viewData);
return $context;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Spatie\LaravelIgnition\Exceptions;
use Spatie\Ignition\Contracts\ProvidesSolution;
use Spatie\Ignition\Contracts\Solution;
class ViewExceptionWithSolution extends ViewException implements ProvidesSolution
{
protected Solution $solution;
public function setSolution(Solution $solution): void
{
$this->solution = $solution;
}
public function getSolution(): Solution
{
return $this->solution;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Spatie\LaravelIgnition\Facades;
use Illuminate\Support\Facades\Facade;
use Spatie\LaravelIgnition\Support\SentReports;
/**
* @method static void glow(string $name, string $messageLevel = \Spatie\FlareClient\Enums\MessageLevels::INFO, array $metaData = [])
* @method static void context($key, $value)
* @method static void group(string $groupName, array $properties)
*
* @see \Spatie\FlareClient\Flare
*/
class Flare extends Facade
{
protected static function getFacadeAccessor()
{
return \Spatie\FlareClient\Flare::class;
}
public static function sentReports(): SentReports
{
return app(SentReports::class);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Spatie\LaravelIgnition\FlareMiddleware;
use Closure;
use Illuminate\Log\Context\Repository;
use Illuminate\Support\Facades\Context;
use Spatie\FlareClient\FlareMiddleware\FlareMiddleware;
use Spatie\FlareClient\Report;
class AddContext implements FlareMiddleware
{
public function handle(Report $report, Closure $next)
{
if (! class_exists(Repository::class)) {
return $report;
}
$allContext = Context::all();
if (count($allContext)) {
$report->group('laravel_context', $allContext);
}
return $report;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Spatie\LaravelIgnition\FlareMiddleware;
use Closure;
use Spatie\FlareClient\FlareMiddleware\FlareMiddleware;
use Spatie\FlareClient\Report;
use Spatie\LaravelIgnition\Recorders\DumpRecorder\DumpRecorder;
class AddDumps implements FlareMiddleware
{
protected DumpRecorder $dumpRecorder;
public function __construct()
{
$this->dumpRecorder = app(DumpRecorder::class);
}
public function handle(Report $report, Closure $next)
{
$report->group('dumps', $this->dumpRecorder->getDumps());
return $next($report);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Spatie\LaravelIgnition\FlareMiddleware;
use Closure;
use Spatie\FlareClient\FlareMiddleware\FlareMiddleware;
use Spatie\FlareClient\Report;
class AddEnvironmentInformation implements FlareMiddleware
{
public function handle(Report $report, Closure $next)
{
$report->frameworkVersion(app()->version());
$report->group('env', [
'laravel_version' => app()->version(),
'laravel_locale' => app()->getLocale(),
'laravel_config_cached' => app()->configurationIsCached(),
'app_debug' => config('app.debug'),
'app_env' => config('app.env'),
'php_version' => phpversion(),
]);
return $next($report);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Spatie\LaravelIgnition\FlareMiddleware;
use Illuminate\Database\QueryException;
use Spatie\FlareClient\Contracts\ProvidesFlareContext;
use Spatie\FlareClient\FlareMiddleware\FlareMiddleware;
use Spatie\FlareClient\Report;
class AddExceptionInformation implements FlareMiddleware
{
public function handle(Report $report, $next)
{
$throwable = $report->getThrowable();
$this->addUserDefinedContext($report);
if (! $throwable instanceof QueryException) {
return $next($report);
}
$report->group('exception', [
'raw_sql' => $throwable->getSql(),
]);
return $next($report);
}
private function addUserDefinedContext(Report $report): void
{
$throwable = $report->getThrowable();
if ($throwable === null) {
return;
}
if ($throwable instanceof ProvidesFlareContext) {
// ProvidesFlareContext writes directly to context groups and is handled in the flare-client-php package.
return;
}
if (! method_exists($throwable, 'context')) {
return;
}
$context = $throwable->context();
if (! is_array($context)) {
return;
}
$exceptionContextGroup = [];
foreach ($context as $key => $value) {
$exceptionContextGroup[$key] = $value;
}
$report->group('exception', $exceptionContextGroup);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Spatie\LaravelIgnition\FlareMiddleware;
use Spatie\FlareClient\FlareMiddleware\FlareMiddleware;
use Spatie\FlareClient\Report;
use Spatie\LaravelIgnition\Recorders\JobRecorder\JobRecorder;
class AddJobs implements FlareMiddleware
{
protected JobRecorder $jobRecorder;
public function __construct()
{
$this->jobRecorder = app(JobRecorder::class);
}
public function handle(Report $report, $next)
{
if ($job = $this->jobRecorder->getJob()) {
$report->group('job', $job);
}
return $next($report);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Spatie\LaravelIgnition\FlareMiddleware;
use Spatie\FlareClient\FlareMiddleware\FlareMiddleware;
use Spatie\FlareClient\Report;
use Spatie\LaravelIgnition\Recorders\LogRecorder\LogRecorder;
class AddLogs implements FlareMiddleware
{
protected LogRecorder $logRecorder;
public function __construct()
{
$this->logRecorder = app(LogRecorder::class);
}
public function handle(Report $report, $next)
{
$report->group('logs', $this->logRecorder->getLogMessages());
return $next($report);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Spatie\LaravelIgnition\FlareMiddleware;
use Spatie\FlareClient\FlareMiddleware\FlareMiddleware;
use Spatie\FlareClient\Report;
class AddNotifierName implements FlareMiddleware
{
public const NOTIFIER_NAME = 'Laravel Client';
public function handle(Report $report, $next)
{
$report->notifierName(static::NOTIFIER_NAME);
return $next($report);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Spatie\LaravelIgnition\FlareMiddleware;
use Spatie\FlareClient\Report;
use Spatie\LaravelIgnition\Recorders\QueryRecorder\QueryRecorder;
class AddQueries
{
protected QueryRecorder $queryRecorder;
public function __construct()
{
$this->queryRecorder = app(QueryRecorder::class);
}
public function handle(Report $report, $next)
{
$report->group('queries', $this->queryRecorder->getQueries());
return $next($report);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Spatie\LaravelIgnition\Http\Controllers;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Spatie\Ignition\Contracts\SolutionProviderRepository;
use Spatie\LaravelIgnition\Exceptions\CannotExecuteSolutionForNonLocalIp;
use Spatie\LaravelIgnition\Http\Requests\ExecuteSolutionRequest;
use Spatie\LaravelIgnition\Support\RunnableSolutionsGuard;
class ExecuteSolutionController
{
use ValidatesRequests;
public function __invoke(
ExecuteSolutionRequest $request,
SolutionProviderRepository $solutionProviderRepository
) {
$this
->ensureRunnableSolutionsEnabled()
->ensureLocalRequest();
$solution = $request->getRunnableSolution();
$solution->run($request->get('parameters', []));
return response()->noContent();
}
public function ensureRunnableSolutionsEnabled(): self
{
// Should already be checked in middleware but we want to be 100% certain.
abort_unless(RunnableSolutionsGuard::check(), 400);
return $this;
}
public function ensureLocalRequest(): self
{
$ipIsPublic = filter_var(
request()->ip(),
FILTER_VALIDATE_IP,
FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if ($ipIsPublic) {
throw CannotExecuteSolutionForNonLocalIp::make();
}
return $this;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Spatie\LaravelIgnition\Http\Controllers;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;
class HealthCheckController
{
public function __invoke()
{
return [
'can_execute_commands' => $this->canExecuteCommands(),
];
}
protected function canExecuteCommands(): bool
{
Artisan::call('help', ['--version']);
$output = Artisan::output();
return Str::contains($output, app()->version());
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Spatie\LaravelIgnition\Http\Controllers;
use Spatie\Ignition\Config\IgnitionConfig;
use Spatie\LaravelIgnition\Http\Requests\UpdateConfigRequest;
class UpdateConfigController
{
public function __invoke(UpdateConfigRequest $request)
{
$result = (new IgnitionConfig())->saveValues($request->validated());
return response()->json($result);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Spatie\LaravelIgnition\Http\Middleware;
use Closure;
use Spatie\LaravelIgnition\Support\RunnableSolutionsGuard;
class RunnableSolutionsEnabled
{
public function handle($request, Closure $next)
{
if (! RunnableSolutionsGuard::check()) {
abort(404);
}
return $next($request);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Spatie\LaravelIgnition\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Spatie\Ignition\Contracts\RunnableSolution;
use Spatie\Ignition\Contracts\Solution;
use Spatie\Ignition\Contracts\SolutionProviderRepository;
class ExecuteSolutionRequest extends FormRequest
{
public function rules(): array
{
return [
'solution' => 'required',
'parameters' => 'array',
];
}
public function getSolution(): Solution
{
$solution = app(SolutionProviderRepository::class)
->getSolutionForClass($this->get('solution'));
abort_if(is_null($solution), 404, 'Solution could not be found');
return $solution;
}
public function getRunnableSolution(): RunnableSolution
{
$solution = $this->getSolution();
if (! $solution instanceof RunnableSolution) {
abort(404, 'Runnable solution could not be found');
}
return $solution;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Spatie\LaravelIgnition\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateConfigRequest extends FormRequest
{
public function rules(): array
{
return [
'theme' => ['required', Rule::in(['light', 'dark', 'auto'])],
'editor' => ['required'],
'hide_solutions' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,349 @@
<?php
namespace Spatie\LaravelIgnition;
use Exception;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\ViewException;
use Laravel\Octane\Events\RequestReceived;
use Laravel\Octane\Events\RequestTerminated;
use Laravel\Octane\Events\TaskReceived;
use Laravel\Octane\Events\TickReceived;
use Monolog\Level;
use Monolog\Logger;
use Spatie\FlareClient\Flare;
use Spatie\FlareClient\FlareMiddleware\AddSolutions;
use Spatie\Ignition\Config\FileConfigManager;
use Spatie\Ignition\Config\IgnitionConfig;
use Spatie\Ignition\Contracts\ConfigManager;
use Spatie\Ignition\Contracts\SolutionProviderRepository as SolutionProviderRepositoryContract;
use Spatie\Ignition\Ignition;
use Spatie\LaravelIgnition\Commands\SolutionMakeCommand;
use Spatie\LaravelIgnition\Commands\SolutionProviderMakeCommand;
use Spatie\LaravelIgnition\Commands\TestCommand;
use Spatie\LaravelIgnition\ContextProviders\LaravelContextProviderDetector;
use Spatie\LaravelIgnition\Exceptions\InvalidConfig;
use Spatie\LaravelIgnition\FlareMiddleware\AddJobs;
use Spatie\LaravelIgnition\FlareMiddleware\AddLogs;
use Spatie\LaravelIgnition\FlareMiddleware\AddQueries;
use Spatie\LaravelIgnition\Recorders\DumpRecorder\DumpRecorder;
use Spatie\LaravelIgnition\Recorders\JobRecorder\JobRecorder;
use Spatie\LaravelIgnition\Recorders\LogRecorder\LogRecorder;
use Spatie\LaravelIgnition\Recorders\QueryRecorder\QueryRecorder;
use Spatie\LaravelIgnition\Renderers\IgnitionExceptionRenderer;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\SolutionProviderRepository;
use Spatie\LaravelIgnition\Support\FlareLogHandler;
use Spatie\LaravelIgnition\Support\SentReports;
use Spatie\LaravelIgnition\Views\ViewExceptionMapper;
class IgnitionServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->registerConfig();
$this->registerFlare();
$this->registerIgnition();
$this->registerRenderer();
$this->registerRecorders();
$this->registerLogHandler();
}
public function boot()
{
if ($this->app->runningInConsole()) {
$this->registerCommands();
$this->publishConfigs();
}
$this->registerRoutes();
$this->configureTinker();
$this->configureOctane();
$this->registerViewExceptionMapper();
$this->startRecorders();
$this->configureQueue();
}
protected function registerConfig(): void
{
$this->mergeConfigFrom(__DIR__ . '/../config/flare.php', 'flare');
$this->mergeConfigFrom(__DIR__ . '/../config/ignition.php', 'ignition');
}
protected function registerCommands(): void
{
if ($this->app['config']->get('flare.key')) {
$this->commands([
TestCommand::class,
]);
}
if ($this->app['config']->get('ignition.register_commands')) {
$this->commands([
SolutionMakeCommand::class,
SolutionProviderMakeCommand::class,
]);
}
}
protected function publishConfigs(): void
{
$this->publishes([
__DIR__ . '/../config/ignition.php' => config_path('ignition.php'),
], 'ignition-config');
$this->publishes([
__DIR__ . '/../config/flare.php' => config_path('flare.php'),
], 'flare-config');
}
protected function registerRenderer(): void
{
$this->app->bind(
'Illuminate\Contracts\Foundation\ExceptionRenderer',
fn (Application $app) => $app->make(IgnitionExceptionRenderer::class)
);
}
protected function registerFlare(): void
{
$this->app->singleton(Flare::class, function () {
return Flare::make()
->setApiToken(config('flare.key') ?? '')
->setBaseUrl(config('flare.base_url', 'https://flareapp.io/api'))
->applicationPath(base_path())
->setStage(app()->environment())
->setContextProviderDetector(new LaravelContextProviderDetector())
->registerMiddleware($this->getFlareMiddleware())
->registerMiddleware(new AddSolutions(new SolutionProviderRepository($this->getSolutionProviders())))
->argumentReducers(config('ignition.argument_reducers', []))
->withStackFrameArguments(config('ignition.with_stack_frame_arguments', true));
});
$this->app->singleton(SentReports::class);
}
protected function registerIgnition(): void
{
$this->app->singleton(
ConfigManager::class,
fn () => new FileConfigManager(config('ignition.settings_file_path', ''))
);
$ignitionConfig = (new IgnitionConfig())
->merge(config('ignition', []))
->loadConfigFile();
$solutionProviders = $this->getSolutionProviders();
$solutionProviderRepository = new SolutionProviderRepository($solutionProviders);
$this->app->singleton(IgnitionConfig::class, fn () => $ignitionConfig);
$this->app->singleton(SolutionProviderRepositoryContract::class, fn () => $solutionProviderRepository);
$this->app->singleton(
Ignition::class,
fn () => (new Ignition($this->app->make(Flare::class)))->applicationPath(base_path())
);
}
protected function registerRecorders(): void
{
$this->app->singleton(DumpRecorder::class);
$this->app->singleton(LogRecorder::class, function (Application $app): LogRecorder {
return new LogRecorder(
$app,
config()->get('flare.flare_middleware.' . AddLogs::class . '.maximum_number_of_collected_logs')
);
});
$this->app->singleton(
QueryRecorder::class,
function (Application $app): QueryRecorder {
return new QueryRecorder(
$app,
config('flare.flare_middleware.' . AddQueries::class . '.report_query_bindings', true),
config('flare.flare_middleware.' . AddQueries::class . '.maximum_number_of_collected_queries', 200)
);
}
);
$this->app->singleton(JobRecorder::class, function (Application $app): JobRecorder {
return new JobRecorder(
$app,
config('flare.flare_middleware.' . AddJobs::class . '.max_chained_job_reporting_depth', 5)
);
});
}
public function configureTinker(): void
{
if ($this->app->runningInConsole()) {
if (isset($_SERVER['argv']) && ['artisan', 'tinker'] === $_SERVER['argv']) {
app(Flare::class)->sendReportsImmediately();
}
}
}
protected function configureOctane(): void
{
if (isset($_SERVER['LARAVEL_OCTANE'])) {
$this->setupOctane();
}
}
protected function registerViewExceptionMapper(): void
{
$handler = $this->app->make(ExceptionHandler::class);
if (! method_exists($handler, 'map')) {
return;
}
$handler->map(function (ViewException $viewException) {
return $this->app->make(ViewExceptionMapper::class)->map($viewException);
});
}
protected function registerRoutes(): void
{
$this->loadRoutesFrom(realpath(__DIR__ . '/ignition-routes.php'));
}
protected function registerLogHandler(): void
{
$this->app->singleton('flare.logger', function ($app) {
$handler = new FlareLogHandler(
$app->make(Flare::class),
$app->make(SentReports::class),
);
$logLevelString = config('logging.channels.flare.level', 'error');
$logLevel = $this->getLogLevel($logLevelString);
$handler->setMinimumReportLogLevel($logLevel);
return tap(
new Logger('Flare'),
fn (Logger $logger) => $logger->pushHandler($handler)
);
});
Log::extend('flare', fn ($app) => $app['flare.logger']);
}
protected function startRecorders(): void
{
foreach ($this->app->config['ignition.recorders'] ?? [] as $recorder) {
$this->app->make($recorder)->start();
}
}
protected function configureQueue(): void
{
if (! $this->app->bound('queue')) {
return;
}
$queue = $this->app->get('queue');
// Reset before executing a queue job to make sure the job's log/query/dump recorders are empty.
// When using a sync queue this also reports the queued reports from previous exceptions.
$queue->before(function () {
$this->resetFlareAndLaravelIgnition();
app(Flare::class)->sendReportsImmediately();
});
// Send queued reports (and reset) after executing a queue job.
$queue->after(function () {
$this->resetFlareAndLaravelIgnition();
});
// Note: the $queue->looping() event can't be used because it's not triggered on Vapor
}
protected function getLogLevel(string $logLevelString): int
{
try {
$logLevel = Level::fromName($logLevelString);
} catch (Exception $exception) {
$logLevel = null;
}
if (! $logLevel) {
throw InvalidConfig::invalidLogLevel($logLevelString);
}
return $logLevel->value;
}
protected function getFlareMiddleware(): array
{
return collect(config('flare.flare_middleware'))
->map(function ($value, $key) {
if (is_string($key)) {
$middlewareClass = $key;
$parameters = $value ?? [];
} else {
$middlewareClass = $value;
$parameters = [];
}
return new $middlewareClass(...array_values($parameters));
})
->values()
->toArray();
}
protected function getSolutionProviders(): array
{
return collect(config('ignition.solution_providers'))
->reject(
fn (string $class) => in_array($class, config('ignition.ignored_solution_providers'))
)
->toArray();
}
protected function setupOctane(): void
{
$this->app['events']->listen(RequestReceived::class, function () {
$this->resetFlareAndLaravelIgnition();
});
$this->app['events']->listen(TaskReceived::class, function () {
$this->resetFlareAndLaravelIgnition();
});
$this->app['events']->listen(TickReceived::class, function () {
$this->resetFlareAndLaravelIgnition();
});
$this->app['events']->listen(RequestTerminated::class, function () {
$this->resetFlareAndLaravelIgnition();
});
}
protected function resetFlareAndLaravelIgnition(): void
{
$this->app->get(SentReports::class)->clear();
$this->app->get(Ignition::class)->reset();
if (config('flare.flare_middleware.' . AddLogs::class)) {
$this->app->make(LogRecorder::class)->reset();
}
if (config('flare.flare_middleware.' . AddQueries::class)) {
$this->app->make(QueryRecorder::class)->reset();
}
if (config('flare.flare_middleware.' . AddJobs::class)) {
$this->app->make(JobRecorder::class)->reset();
}
$this->app->make(DumpRecorder::class)->reset();
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Spatie\LaravelIgnition\Recorders\DumpRecorder;
class Dump
{
protected string $htmlDump;
protected ?string $file;
protected ?int $lineNumber;
protected float $microtime;
public function __construct(string $htmlDump, ?string $file, ?int $lineNumber, ?float $microtime = null)
{
$this->htmlDump = $htmlDump;
$this->file = $file;
$this->lineNumber = $lineNumber;
$this->microtime = $microtime ?? microtime(true);
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'html_dump' => $this->htmlDump,
'file' => $this->file,
'line_number' => $this->lineNumber,
'microtime' => $this->microtime,
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Spatie\LaravelIgnition\Recorders\DumpRecorder;
use Symfony\Component\VarDumper\Cloner\VarCloner;
class DumpHandler
{
protected DumpRecorder $dumpRecorder;
public function __construct(DumpRecorder $dumpRecorder)
{
$this->dumpRecorder = $dumpRecorder;
}
public function dump(mixed $value): void
{
$data = (new VarCloner)->cloneVar($value);
$this->dumpRecorder->record($data);
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace Spatie\LaravelIgnition\Recorders\DumpRecorder;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Arr;
use ReflectionMethod;
use ReflectionProperty;
use Symfony\Component\VarDumper\Cloner\Data;
use Symfony\Component\VarDumper\VarDumper;
class DumpRecorder
{
/** @var array<array<int,mixed>> */
protected array $dumps = [];
protected Application $app;
protected static bool $registeredHandler = false;
public function __construct(Application $app)
{
$this->app = $app;
}
public function start(): self
{
$multiDumpHandler = new MultiDumpHandler();
$this->app->singleton(MultiDumpHandler::class, fn () => $multiDumpHandler);
if (! self::$registeredHandler) {
static::$registeredHandler = true;
$this->ensureOriginalHandlerExists();
$originalHandler = VarDumper::setHandler(fn ($dumpedVariable) => $multiDumpHandler->dump($dumpedVariable));
$multiDumpHandler?->addHandler($originalHandler);
$multiDumpHandler->addHandler(fn ($var) => (new DumpHandler($this))->dump($var));
}
return $this;
}
public function record(Data $data): void
{
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 11);
$sourceFrame = $this->findSourceFrame($backtrace);
$file = (string) Arr::get($sourceFrame, 'file');
$lineNumber = (int) Arr::get($sourceFrame, 'line');
$htmlDump = (new HtmlDumper())->dump($data);
$this->dumps[] = new Dump($htmlDump, $file, $lineNumber);
}
public function getDumps(): array
{
return $this->toArray();
}
public function reset()
{
$this->dumps = [];
}
public function toArray(): array
{
$dumps = [];
foreach ($this->dumps as $dump) {
$dumps[] = $dump->toArray();
}
return $dumps;
}
/*
* Only the `VarDumper` knows how to create the orignal HTML or CLI VarDumper.
* Using reflection and the private VarDumper::register() method we can force it
* to create and register a new VarDumper::$handler before we'll overwrite it.
* Of course, we only need to do this if there isn't a registered VarDumper::$handler.
*
* @throws \ReflectionException
*/
protected function ensureOriginalHandlerExists(): void
{
$reflectionProperty = new ReflectionProperty(VarDumper::class, 'handler');
$reflectionProperty->setAccessible(true);
$handler = $reflectionProperty->getValue();
if (! $handler) {
// No handler registered yet, so we'll force VarDumper to create one.
$reflectionMethod = new ReflectionMethod(VarDumper::class, 'register');
$reflectionMethod->setAccessible(true);
$reflectionMethod->invoke(null);
}
}
/**
* Find the first meaningful stack frame that is not the `DumpRecorder` itself.
*
* @template T of array{class?: class-string, function?: string, line?: int, file?: string}
*
* @param array<T> $stacktrace
*
* @return null|T
*/
protected function findSourceFrame(array $stacktrace): ?array
{
$seenVarDumper = false;
foreach ($stacktrace as $frame) {
// Keep looping until we're past the VarDumper::dump() call in Symfony's helper functions file.
if (Arr::get($frame, 'class') === VarDumper::class && Arr::get($frame, 'function') === 'dump') {
$seenVarDumper = true;
continue;
}
if (! $seenVarDumper) {
continue;
}
// Return the next frame in the stack after the VarDumper::dump() call:
return $frame;
}
return null;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Spatie\LaravelIgnition\Recorders\DumpRecorder;
use Symfony\Component\VarDumper\Cloner\Data;
use Symfony\Component\VarDumper\Cloner\VarCloner;
use Symfony\Component\VarDumper\Dumper\HtmlDumper as BaseHtmlDumper;
class HtmlDumper extends BaseHtmlDumper
{
public function __construct($output = null, string $charset = null, int $flags = 0)
{
parent::__construct($output, $charset, $flags);
$this->setDumpHeader('');
}
public function dumpVariable($variable): string
{
$cloner = new VarCloner();
$clonedData = $cloner->cloneVar($variable)->withMaxDepth(3);
return $this->dump($clonedData);
}
public function dump(Data $data, $output = null, array $extraDisplayOptions = []): string
{
return (string)parent::dump($data, true, [
'maxDepth' => 3,
'maxStringLength' => 160,
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Spatie\LaravelIgnition\Recorders\DumpRecorder;
class MultiDumpHandler
{
/** @var array<int, callable|null> */
protected array $handlers = [];
public function dump(mixed $value): void
{
foreach ($this->handlers as $handler) {
if ($handler) {
$handler($value);
}
}
}
public function addHandler(callable $callable = null): self
{
$this->handlers[] = $callable;
return $this;
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace Spatie\LaravelIgnition\Recorders\JobRecorder;
use DateTime;
use Error;
use Exception;
use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Queue\CallQueuedClosure;
use Illuminate\Queue\Events\JobExceptionOccurred;
use Illuminate\Queue\Jobs\RedisJob;
use Illuminate\Support\Str;
use ReflectionClass;
use ReflectionProperty;
use RuntimeException;
class JobRecorder
{
protected ?Job $job = null;
public function __construct(
protected Application $app,
protected int $maxChainedJobReportingDepth = 5,
) {
}
public function start(): self
{
/** @phpstan-ignore-next-line */
$this->app['events']->listen(JobExceptionOccurred::class, [$this, 'record']);
return $this;
}
public function record(JobExceptionOccurred $event): void
{
$this->job = $event->job;
}
/**
* @return array<string, mixed>|null
*/
public function getJob(): ?array
{
if ($this->job === null) {
return null;
}
return array_merge(
$this->getJobProperties(),
[
'name' => $this->job->resolveName(),
'connection' => $this->job->getConnectionName(),
'queue' => $this->job->getQueue(),
]
);
}
public function reset(): void
{
$this->job = null;
}
protected function getJobProperties(): array
{
$payload = collect($this->resolveJobPayload());
$properties = [];
foreach ($payload as $key => $value) {
if (! in_array($key, ['job', 'data', 'displayName'])) {
$properties[$key] = $value;
}
}
try {
if (is_string($payload['data'])) {
$properties['data'] = json_decode($payload['data'], true, 512, JSON_THROW_ON_ERROR);
}
} catch (Exception $exception) {
}
if ($pushedAt = DateTime::createFromFormat('U.u', $payload->get('pushedAt', ''))) {
$properties['pushedAt'] = $pushedAt->format(DATE_ATOM);
}
try {
$properties['data'] = $this->resolveCommandProperties(
$this->resolveObjectFromCommand($payload['data']['command']),
$this->maxChainedJobReportingDepth
);
} catch (Exception $exception) {
}
return $properties;
}
protected function resolveJobPayload(): array
{
if (! $this->job instanceof RedisJob) {
return $this->job->payload();
}
try {
return json_decode($this->job->getReservedJob(), true, 512, JSON_THROW_ON_ERROR);
} catch (Exception $e) {
return $this->job->payload();
}
}
protected function resolveCommandProperties(object $command, int $maxChainDepth): array
{
$propertiesToIgnore = ['job', 'closure'];
$properties = collect((new ReflectionClass($command))->getProperties())
->reject(function (ReflectionProperty $property) use ($propertiesToIgnore) {
return in_array($property->name, $propertiesToIgnore);
})
->mapWithKeys(function (ReflectionProperty $property) use ($command) {
try {
$property->setAccessible(true);
return [$property->name => $property->getValue($command)];
} catch (Error $error) {
return [$property->name => 'uninitialized'];
}
});
if ($properties->has('chained')) {
$properties['chained'] = $this->resolveJobChain($properties->get('chained'), $maxChainDepth);
}
return $properties->all();
}
/**
* @param array<string, mixed> $chainedCommands
* @param int $maxDepth
*
* @return array
*/
protected function resolveJobChain(array $chainedCommands, int $maxDepth): array
{
if ($maxDepth === 0) {
return ['Ignition stopped recording jobs after this point since the max chain depth was reached'];
}
return array_map(
function (string $command) use ($maxDepth) {
$commandObject = $this->resolveObjectFromCommand($command);
return [
'name' => $commandObject instanceof CallQueuedClosure ? $commandObject->displayName() : get_class($commandObject),
'data' => $this->resolveCommandProperties($commandObject, $maxDepth - 1),
];
},
$chainedCommands
);
}
// Taken from Illuminate\Queue\CallQueuedHandler
protected function resolveObjectFromCommand(string $command): object
{
if (Str::startsWith($command, 'O:')) {
return unserialize($command);
}
if ($this->app->bound(Encrypter::class)) {
/** @phpstan-ignore-next-line */
return unserialize($this->app[Encrypter::class]->decrypt($command));
}
throw new RuntimeException('Unable to extract job payload.');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Spatie\LaravelIgnition\Recorders\LogRecorder;
use Illuminate\Log\Events\MessageLogged;
class LogMessage
{
protected ?string $message;
protected string $level;
/** @var array<string, string> */
protected array $context = [];
protected ?float $microtime;
/**
* @param string|null $message
* @param string $level
* @param array<string, string> $context
* @param float|null $microtime
*/
public function __construct(
?string $message,
string $level,
array $context = [],
?float $microtime = null
) {
$this->message = $message;
$this->level = $level;
$this->context = $context;
$this->microtime = $microtime ?? microtime(true);
}
public static function fromMessageLoggedEvent(MessageLogged $event): self
{
return new self(
$event->message,
$event->level,
$event->context
);
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'message' => $this->message,
'level' => $this->level,
'context' => $this->context,
'microtime' => $this->microtime,
];
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Spatie\LaravelIgnition\Recorders\LogRecorder;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Log\Events\MessageLogged;
use Throwable;
class LogRecorder
{
/** @var \Spatie\LaravelIgnition\Recorders\LogRecorder\LogMessage[] */
protected array $logMessages = [];
protected Application $app;
protected ?int $maxLogs;
public function __construct(Application $app, ?int $maxLogs = null)
{
$this->app = $app;
$this->maxLogs = $maxLogs;
}
public function start(): self
{
/** @phpstan-ignore-next-line */
$this->app['events']->listen(MessageLogged::class, [$this, 'record']);
return $this;
}
public function record(MessageLogged $event): void
{
if ($this->shouldIgnore($event)) {
return;
}
$this->logMessages[] = LogMessage::fromMessageLoggedEvent($event);
if (is_int($this->maxLogs)) {
$this->logMessages = array_slice($this->logMessages, -$this->maxLogs);
}
}
/** @return array<array<int,string>> */
public function getLogMessages(): array
{
return $this->toArray();
}
/** @return array<int, mixed> */
public function toArray(): array
{
$logMessages = [];
foreach ($this->logMessages as $log) {
$logMessages[] = $log->toArray();
}
return $logMessages;
}
protected function shouldIgnore(mixed $event): bool
{
if (! isset($event->context['exception'])) {
return false;
}
if (! $event->context['exception'] instanceof Throwable) {
return false;
}
return true;
}
public function reset(): void
{
$this->logMessages = [];
}
public function getMaxLogs(): ?int
{
return $this->maxLogs;
}
public function setMaxLogs(?int $maxLogs): self
{
$this->maxLogs = $maxLogs;
return $this;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Spatie\LaravelIgnition\Recorders\QueryRecorder;
use Illuminate\Database\Events\QueryExecuted;
class Query
{
protected string $sql;
protected float $time;
protected string $connectionName;
/** @var array<string, string>|null */
protected ?array $bindings;
protected float $microtime;
public static function fromQueryExecutedEvent(QueryExecuted $queryExecuted, bool $reportBindings = false): self
{
return new self(
$queryExecuted->sql,
$queryExecuted->time,
/** @phpstan-ignore-next-line */
$queryExecuted->connectionName ?? '',
$reportBindings ? $queryExecuted->bindings : null
);
}
/**
* @param string $sql
* @param float $time
* @param string $connectionName
* @param array<string, string>|null $bindings
* @param float|null $microtime
*/
protected function __construct(
string $sql,
float $time,
string $connectionName,
?array $bindings = null,
?float $microtime = null
) {
$this->sql = $sql;
$this->time = $time;
$this->connectionName = $connectionName;
$this->bindings = $bindings;
$this->microtime = $microtime ?? microtime(true);
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'sql' => $this->sql,
'time' => $this->time,
'connection_name' => $this->connectionName,
'bindings' => $this->bindings,
'microtime' => $this->microtime,
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Spatie\LaravelIgnition\Recorders\QueryRecorder;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Database\Events\QueryExecuted;
class QueryRecorder
{
/** @var \Spatie\LaravelIgnition\Recorders\QueryRecorder\Query[] */
protected array $queries = [];
protected Application $app;
protected bool $reportBindings = true;
protected ?int $maxQueries;
public function __construct(
Application $app,
bool $reportBindings = true,
?int $maxQueries = 200
) {
$this->app = $app;
$this->reportBindings = $reportBindings;
$this->maxQueries = $maxQueries;
}
public function start(): self
{
/** @phpstan-ignore-next-line */
$this->app['events']->listen(QueryExecuted::class, [$this, 'record']);
return $this;
}
public function record(QueryExecuted $queryExecuted): void
{
$this->queries[] = Query::fromQueryExecutedEvent($queryExecuted, $this->reportBindings);
if (is_int($this->maxQueries)) {
$this->queries = array_slice($this->queries, -$this->maxQueries);
}
}
/**
* @return array<int, array<string, mixed>>
*/
public function getQueries(): array
{
$queries = [];
foreach ($this->queries as $query) {
$queries[] = $query->toArray();
}
return $queries;
}
public function reset(): void
{
$this->queries = [];
}
public function getReportBindings(): bool
{
return $this->reportBindings;
}
public function setReportBindings(bool $reportBindings): self
{
$this->reportBindings = $reportBindings;
return $this;
}
public function getMaxQueries(): ?int
{
return $this->maxQueries;
}
public function setMaxQueries(?int $maxQueries): self
{
$this->maxQueries = $maxQueries;
return $this;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Spatie\LaravelIgnition\Renderers;
use Spatie\FlareClient\Flare;
use Spatie\Ignition\Config\IgnitionConfig;
use Spatie\Ignition\Contracts\SolutionProviderRepository;
use Spatie\Ignition\Ignition;
use Spatie\LaravelIgnition\ContextProviders\LaravelContextProviderDetector;
use Spatie\LaravelIgnition\Solutions\SolutionTransformers\LaravelSolutionTransformer;
use Spatie\LaravelIgnition\Support\LaravelDocumentationLinkFinder;
use Throwable;
class ErrorPageRenderer
{
public function render(Throwable $throwable): void
{
$viteJsAutoRefresh = '';
if (class_exists('Illuminate\Foundation\Vite')) {
$vite = app(\Illuminate\Foundation\Vite::class);
if (is_file($vite->hotFile())) {
$viteJsAutoRefresh = $vite->__invoke([]);
}
}
app(Ignition::class)
->resolveDocumentationLink(
fn (Throwable $throwable) => (new LaravelDocumentationLinkFinder())->findLinkForThrowable($throwable)
)
->setFlare(app(Flare::class))
->setConfig(app(IgnitionConfig::class))
->setSolutionProviderRepository(app(SolutionProviderRepository::class))
->setContextProviderDetector(new LaravelContextProviderDetector())
->setSolutionTransformerClass(LaravelSolutionTransformer::class)
->applicationPath(base_path())
->addCustomHtmlToHead($viteJsAutoRefresh)
->renderException($throwable);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Spatie\LaravelIgnition\Renderers;
use Illuminate\Contracts\Foundation\ExceptionRenderer;
class IgnitionExceptionRenderer implements ExceptionRenderer
{
protected ErrorPageRenderer $errorPageHandler;
public function __construct(ErrorPageRenderer $errorPageHandler)
{
$this->errorPageHandler = $errorPageHandler;
}
public function render($throwable)
{
ob_start();
$this->errorPageHandler->render($throwable);
return ob_get_clean();
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Illuminate\Support\Facades\Artisan;
use Spatie\Ignition\Contracts\RunnableSolution;
class GenerateAppKeySolution implements RunnableSolution
{
public function getSolutionTitle(): string
{
return 'Your app key is missing';
}
public function getDocumentationLinks(): array
{
return [
'Laravel installation' => 'https://laravel.com/docs/master/installation#configuration',
];
}
public function getSolutionActionDescription(): string
{
return 'Generate your application encryption key using `php artisan key:generate`.';
}
public function getRunButtonText(): string
{
return 'Generate app key';
}
public function getSolutionDescription(): string
{
return '';
}
public function getRunParameters(): array
{
return [];
}
public function run(array $parameters = []): void
{
Artisan::call('key:generate');
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Livewire\LivewireComponentsFinder;
use Spatie\Ignition\Contracts\RunnableSolution;
class LivewireDiscoverSolution implements RunnableSolution
{
protected string $customTitle;
public function __construct(string $customTitle = '')
{
$this->customTitle = $customTitle;
}
public function getSolutionTitle(): string
{
return $this->customTitle;
}
public function getSolutionDescription(): string
{
return 'You might have forgotten to discover your Livewire components.';
}
public function getDocumentationLinks(): array
{
return [
'Livewire: Artisan Commands' => 'https://laravel-livewire.com/docs/2.x/artisan-commands',
];
}
public function getRunParameters(): array
{
return [];
}
public function getSolutionActionDescription(): string
{
return 'You can discover your Livewire components using `php artisan livewire:discover`.';
}
public function getRunButtonText(): string
{
return 'Run livewire:discover';
}
public function run(array $parameters = []): void
{
app(LivewireComponentsFinder::class)->build();
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Str;
use Spatie\Ignition\Contracts\RunnableSolution;
class MakeViewVariableOptionalSolution implements RunnableSolution
{
protected ?string $variableName;
protected ?string $viewFile;
public function __construct(string $variableName = null, string $viewFile = null)
{
$this->variableName = $variableName;
$this->viewFile = $viewFile;
}
public function getSolutionTitle(): string
{
return "$$this->variableName is undefined";
}
public function getDocumentationLinks(): array
{
return [];
}
public function getSolutionActionDescription(): string
{
$output = [
'Make the variable optional in the blade template.',
"Replace `{{ $$this->variableName }}` with `{{ $$this->variableName ?? '' }}`",
];
return implode(PHP_EOL, $output);
}
public function getRunButtonText(): string
{
return 'Make variable optional';
}
public function getSolutionDescription(): string
{
return '';
}
public function getRunParameters(): array
{
return [
'variableName' => $this->variableName,
'viewFile' => $this->viewFile,
];
}
/**
* @param array<string, mixed> $parameters
*
* @return bool
*/
public function isRunnable(array $parameters = []): bool
{
return $this->makeOptional($this->getRunParameters()) !== false;
}
/**
* @param array<string, string> $parameters
*
* @return void
*/
public function run(array $parameters = []): void
{
$output = $this->makeOptional($parameters);
if ($output !== false) {
file_put_contents($parameters['viewFile'], $output);
}
}
protected function isSafePath(string $path): bool
{
if (! Str::startsWith($path, ['/', './'])) {
return false;
}
if (! Str::endsWith($path, '.blade.php')) {
return false;
}
return true;
}
/**
* @param array<string, string> $parameters
*
* @return bool|string
*/
public function makeOptional(array $parameters = []): bool|string
{
if (! $this->isSafePath($parameters['viewFile'])) {
return false;
}
$originalContents = (string)file_get_contents($parameters['viewFile']);
$newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);
$originalTokens = token_get_all(Blade::compileString($originalContents));
$newTokens = token_get_all(Blade::compileString($newContents));
$expectedTokens = $this->generateExpectedTokens($originalTokens, $parameters['variableName']);
if ($expectedTokens !== $newTokens) {
return false;
}
return $newContents;
}
/**
* @param array<int, mixed> $originalTokens
* @param string $variableName
*
* @return array<int, mixed>
*/
protected function generateExpectedTokens(array $originalTokens, string $variableName): array
{
$expectedTokens = [];
foreach ($originalTokens as $token) {
$expectedTokens[] = $token;
if ($token[0] === T_VARIABLE && $token[1] === '$'.$variableName) {
$expectedTokens[] = [T_WHITESPACE, ' ', $token[2]];
$expectedTokens[] = [T_COALESCE, '??', $token[2]];
$expectedTokens[] = [T_WHITESPACE, ' ', $token[2]];
$expectedTokens[] = [T_CONSTANT_ENCAPSED_STRING, "''", $token[2]];
}
}
return $expectedTokens;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Illuminate\Support\Facades\Artisan;
use Spatie\Ignition\Contracts\RunnableSolution;
class RunMigrationsSolution implements RunnableSolution
{
protected string $customTitle;
public function __construct(string $customTitle = '')
{
$this->customTitle = $customTitle;
}
public function getSolutionTitle(): string
{
return $this->customTitle;
}
public function getSolutionDescription(): string
{
return 'You might have forgotten to run your database migrations.';
}
public function getDocumentationLinks(): array
{
return [
'Database: Running Migrations docs' => 'https://laravel.com/docs/master/migrations#running-migrations',
];
}
public function getRunParameters(): array
{
return [];
}
public function getSolutionActionDescription(): string
{
return 'You can try to run your migrations using `php artisan migrate`.';
}
public function getRunButtonText(): string
{
return 'Run migrations';
}
public function run(array $parameters = []): void
{
Artisan::call('migrate');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Database\QueryException;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Solutions\SuggestUsingCorrectDbNameSolution;
use Throwable;
class DefaultDbNameSolutionProvider implements HasSolutionsForThrowable
{
const MYSQL_UNKNOWN_DATABASE_CODE = 1049;
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof QueryException) {
return false;
}
if ($throwable->getCode() !== self::MYSQL_UNKNOWN_DATABASE_CODE) {
return false;
}
if (! in_array(env('DB_DATABASE'), ['homestead', 'laravel'])) {
return false;
}
return true;
}
public function getSolutions(Throwable $throwable): array
{
return [new SuggestUsingCorrectDbNameSolution()];
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Broadcasting\BroadcastException;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Support\LaravelVersion;
use Throwable;
class GenericLaravelExceptionSolutionProvider implements HasSolutionsForThrowable
{
public function canSolve(Throwable $throwable): bool
{
return ! is_null($this->getSolutionTexts($throwable));
}
public function getSolutions(Throwable $throwable): array
{
if (! $texts = $this->getSolutionTexts($throwable)) {
return [];
}
$solution = BaseSolution::create($texts['title'])
->setSolutionDescription($texts['description'])
->setDocumentationLinks($texts['links']);
return ([$solution]);
}
/**
* @param \Throwable $throwable
*
* @return array<string, mixed>|null
*/
protected function getSolutionTexts(Throwable $throwable) : ?array
{
foreach ($this->getSupportedExceptions() as $supportedClass => $texts) {
if ($throwable instanceof $supportedClass) {
return $texts;
}
}
return null;
}
/** @return array<string, mixed> */
protected function getSupportedExceptions(): array
{
$majorVersion = LaravelVersion::major();
return
[
BroadcastException::class => [
'title' => 'Here are some links that might help solve this problem',
'description' => '',
'links' => [
'Laravel docs on authentication' => "https://laravel.com/docs/{$majorVersion}.x/authentication",
],
],
];
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Database\QueryException;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Solutions\UseDefaultValetDbCredentialsSolution;
use Throwable;
class IncorrectValetDbCredentialsSolutionProvider implements HasSolutionsForThrowable
{
const MYSQL_ACCESS_DENIED_CODE = 1045;
public function canSolve(Throwable $throwable): bool
{
if (PHP_OS !== 'Darwin') {
return false;
}
if (! $throwable instanceof QueryException) {
return false;
}
if (! $this->isAccessDeniedCode($throwable->getCode())) {
return false;
}
if (! $this->envFileExists()) {
return false;
}
if (! $this->isValetInstalled()) {
return false;
}
if ($this->usingCorrectDefaultCredentials()) {
return false;
}
return true;
}
public function getSolutions(Throwable $throwable): array
{
return [new UseDefaultValetDbCredentialsSolution()];
}
protected function envFileExists(): bool
{
return file_exists(base_path('.env'));
}
protected function isAccessDeniedCode(string $code): bool
{
return $code === static::MYSQL_ACCESS_DENIED_CODE;
}
protected function isValetInstalled(): bool
{
return file_exists('/usr/local/bin/valet');
}
protected function usingCorrectDefaultCredentials(): bool
{
return env('DB_USERNAME') === 'root' && env('DB_PASSWORD') === '';
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Support\Str;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Support\Composer\ComposerClassMap;
use Spatie\LaravelIgnition\Support\StringComparator;
use Throwable;
use UnexpectedValueException;
class InvalidRouteActionSolutionProvider implements HasSolutionsForThrowable
{
protected const REGEX = '/\[([a-zA-Z\\\\]+)\]/m';
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof UnexpectedValueException) {
return false;
}
if (! preg_match(self::REGEX, $throwable->getMessage(), $matches)) {
return false;
}
return Str::startsWith($throwable->getMessage(), 'Invalid route action: ');
}
public function getSolutions(Throwable $throwable): array
{
preg_match(self::REGEX, $throwable->getMessage(), $matches);
$invalidController = $matches[1] ?? null;
$suggestedController = $this->findRelatedController($invalidController);
if ($suggestedController === $invalidController) {
return [
BaseSolution::create("`{$invalidController}` is not invokable.")
->setSolutionDescription("The controller class `{$invalidController}` is not invokable. Did you forget to add the `__invoke` method or is the controller's method missing in your routes file?"),
];
}
if ($suggestedController) {
return [
BaseSolution::create("`{$invalidController}` was not found.")
->setSolutionDescription("Controller class `{$invalidController}` for one of your routes was not found. Did you mean `{$suggestedController}`?"),
];
}
return [
BaseSolution::create("`{$invalidController}` was not found.")
->setSolutionDescription("Controller class `{$invalidController}` for one of your routes was not found. Are you sure this controller exists and is imported correctly?"),
];
}
protected function findRelatedController(string $invalidController): ?string
{
$composerClassMap = app(ComposerClassMap::class);
$controllers = collect($composerClassMap->listClasses())
->filter(function (string $file, string $fqcn) {
return Str::endsWith($fqcn, 'Controller');
})
->mapWithKeys(function (string $file, string $fqcn) {
return [$fqcn => class_basename($fqcn)];
})
->toArray();
$basenameMatch = StringComparator::findClosestMatch($controllers, $invalidController, 4);
$controllers = array_flip($controllers);
$fqcnMatch = StringComparator::findClosestMatch($controllers, $invalidController, 4);
return $fqcnMatch ?? $basenameMatch;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Database\LazyLoadingViolationException;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Support\LaravelVersion;
use Throwable;
class LazyLoadingViolationSolutionProvider implements HasSolutionsForThrowable
{
public function canSolve(Throwable $throwable): bool
{
if ($throwable instanceof LazyLoadingViolationException) {
return true;
}
if (! $previous = $throwable->getPrevious()) {
return false;
}
return $previous instanceof LazyLoadingViolationException;
}
public function getSolutions(Throwable $throwable): array
{
$majorVersion = LaravelVersion::major();
return [BaseSolution::create(
'Lazy loading was disabled to detect N+1 problems'
)
->setSolutionDescription(
'Either avoid lazy loading the relation or allow lazy loading.'
)
->setDocumentationLinks([
'Read the docs on preventing lazy loading' => "https://laravel.com/docs/{$majorVersion}.x/eloquent-relationships#preventing-lazy-loading",
'Watch a video on how to deal with the N+1 problem' => 'https://www.youtube.com/watch?v=ZE7KBeraVpc',
]),];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use RuntimeException;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Solutions\GenerateAppKeySolution;
use Throwable;
class MissingAppKeySolutionProvider implements HasSolutionsForThrowable
{
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof RuntimeException) {
return false;
}
return $throwable->getMessage() === 'No application encryption key has been specified.';
}
public function getSolutions(Throwable $throwable): array
{
return [new GenerateAppKeySolution()];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Database\QueryException;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Solutions\RunMigrationsSolution;
use Throwable;
class MissingColumnSolutionProvider implements HasSolutionsForThrowable
{
/**
* See https://dev.mysql.com/doc/refman/8.0/en/server-error-reference.html#error_er_bad_field_error.
*/
const MYSQL_BAD_FIELD_CODE = '42S22';
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof QueryException) {
return false;
}
return $this->isBadTableErrorCode($throwable->getCode());
}
protected function isBadTableErrorCode(string $code): bool
{
return $code === static::MYSQL_BAD_FIELD_CODE;
}
public function getSolutions(Throwable $throwable): array
{
return [new RunMigrationsSolution('A column was not found')];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Solutions\SuggestImportSolution;
use Spatie\LaravelIgnition\Support\Composer\ComposerClassMap;
use Throwable;
class MissingImportSolutionProvider implements HasSolutionsForThrowable
{
protected ?string $foundClass;
protected ComposerClassMap $composerClassMap;
public function canSolve(Throwable $throwable): bool
{
$pattern = '/Class \"([^\s]+)\" not found/m';
if (! preg_match($pattern, $throwable->getMessage(), $matches)) {
return false;
}
$class = $matches[1];
$this->composerClassMap = new ComposerClassMap();
$this->search($class);
return ! is_null($this->foundClass);
}
/**
* @param \Throwable $throwable
*
* @return array<int, SuggestImportSolution>
*/
public function getSolutions(Throwable $throwable): array
{
if (is_null($this->foundClass)) {
return [];
}
return [new SuggestImportSolution($this->foundClass)];
}
protected function search(string $missingClass): void
{
$this->foundClass = $this->composerClassMap->searchClassMap($missingClass);
if (is_null($this->foundClass)) {
$this->foundClass = $this->composerClassMap->searchPsrMaps($missingClass);
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Livewire\Exceptions\ComponentNotFoundException;
use Livewire\LivewireComponentsFinder;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Solutions\LivewireDiscoverSolution;
use Throwable;
class MissingLivewireComponentSolutionProvider implements HasSolutionsForThrowable
{
public function canSolve(Throwable $throwable): bool
{
if (! $this->livewireIsInstalled()) {
return false;
}
if (! $throwable instanceof ComponentNotFoundException) {
return false;
}
return true;
}
public function getSolutions(Throwable $throwable): array
{
return [new LivewireDiscoverSolution('A Livewire component was not found')];
}
public function livewireIsInstalled(): bool
{
if (! class_exists(ComponentNotFoundException::class)) {
return false;
}
if (! class_exists(LivewireComponentsFinder::class)) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Support\Str;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Throwable;
class MissingMixManifestSolutionProvider implements HasSolutionsForThrowable
{
public function canSolve(Throwable $throwable): bool
{
return Str::startsWith($throwable->getMessage(), 'Mix manifest not found');
}
public function getSolutions(Throwable $throwable): array
{
return [
BaseSolution::create('Missing Mix Manifest File')
->setSolutionDescription('Did you forget to run `npm install && npm run dev`?'),
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Support\Str;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\Ignition\Contracts\Solution;
use Throwable;
class MissingViteManifestSolutionProvider implements HasSolutionsForThrowable
{
/** @var array<string, string> */
protected array $links = [
'Asset bundling with Vite' => 'https://laravel.com/docs/9.x/vite#running-vite',
];
public function canSolve(Throwable $throwable): bool
{
return Str::startsWith($throwable->getMessage(), 'Vite manifest not found');
}
public function getSolutions(Throwable $throwable): array
{
return [
$this->getSolution(),
];
}
public function getSolution(): Solution
{
/** @var string */
$baseCommand = collect([
'pnpm-lock.yaml' => 'pnpm',
'yarn.lock' => 'yarn',
])->first(fn ($_, $lockfile) => file_exists(base_path($lockfile)), 'npm run');
return app()->environment('local')
? $this->getLocalSolution($baseCommand)
: $this->getProductionSolution($baseCommand);
}
protected function getLocalSolution(string $baseCommand): Solution
{
return BaseSolution::create('Start the development server')
->setSolutionDescription("Run `{$baseCommand} dev` in your terminal and refresh the page.")
->setDocumentationLinks($this->links);
}
protected function getProductionSolution(string $baseCommand): Solution
{
return BaseSolution::create('Build the production assets')
->setSolutionDescription("Run `{$baseCommand} build` in your deployment script.")
->setDocumentationLinks($this->links);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Support\Str;
use OpenAI\Client;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\Ignition\Solutions\OpenAi\OpenAiSolutionProvider as BaseOpenAiSolutionProvider;
use Throwable;
class OpenAiSolutionProvider implements HasSolutionsForThrowable
{
public function canSolve(Throwable $throwable): bool
{
if (! class_exists(Client::class)) {
return false;
}
if (config('ignition.open_ai_key') === null) {
return false;
}
return true;
}
public function getSolutions(Throwable $throwable): array
{
$solutionProvider = new BaseOpenAiSolutionProvider(
openAiKey: config('ignition.open_ai_key'),
cache: cache()->store(config('cache.default')),
cacheTtlInSeconds: 60,
applicationType: 'Laravel ' . Str::before(app()->version(), '.'),
applicationPath: base_path(),
);
return $solutionProvider->getSolutions($throwable);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Support\Facades\Route;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Support\StringComparator;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
use Throwable;
class RouteNotDefinedSolutionProvider implements HasSolutionsForThrowable
{
protected const REGEX = '/Route \[(.*)\] not defined/m';
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof RouteNotFoundException) {
return false;
}
return (bool)preg_match(self::REGEX, $throwable->getMessage(), $matches);
}
public function getSolutions(Throwable $throwable): array
{
preg_match(self::REGEX, $throwable->getMessage(), $matches);
$missingRoute = $matches[1] ?? '';
$suggestedRoute = $this->findRelatedRoute($missingRoute);
if ($suggestedRoute) {
return [
BaseSolution::create("{$missingRoute} was not defined.")
->setSolutionDescription("Did you mean `{$suggestedRoute}`?"),
];
}
return [
BaseSolution::create("{$missingRoute} was not defined.")
->setSolutionDescription('Are you sure that the route is defined'),
];
}
protected function findRelatedRoute(string $missingRoute): ?string
{
Route::getRoutes()->refreshNameLookups();
return StringComparator::findClosestMatch(array_keys(Route::getRoutes()->getRoutesByName()), $missingRoute);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Exception;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Throwable;
class RunningLaravelDuskInProductionProvider implements HasSolutionsForThrowable
{
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof Exception) {
return false;
}
return $throwable->getMessage() === 'It is unsafe to run Dusk in production.';
}
public function getSolutions(Throwable $throwable): array
{
return [
BaseSolution::create()
->setSolutionTitle('Laravel Dusk should not be run in production.')
->setSolutionDescription('Install the dependencies with the `--no-dev` flag.'),
BaseSolution::create()
->setSolutionTitle('Laravel Dusk can be run in other environments.')
->setSolutionDescription('Consider setting the `APP_ENV` to something other than `production` like `local` for example.'),
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Throwable;
class SailNetworkSolutionProvider implements HasSolutionsForThrowable
{
public function canSolve(Throwable $throwable): bool
{
return app()->runningInConsole()
&& str_contains($throwable->getMessage(), 'php_network_getaddresses')
&& file_exists(base_path('vendor/bin/sail'))
&& file_exists(base_path('docker-compose.yml'))
&& env('LARAVEL_SAIL') === null;
}
public function getSolutions(Throwable $throwable): array
{
return [
BaseSolution::create('Network address not found')
->setSolutionDescription('Did you mean to use `sail artisan`?')
->setDocumentationLinks([
'Sail: Executing Artisan Commands' => 'https://laravel.com/docs/sail#executing-artisan-commands',
]),
];
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Support\Collection;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\Ignition\Contracts\ProvidesSolution;
use Spatie\Ignition\Contracts\Solution;
use Spatie\Ignition\Contracts\SolutionProviderRepository as SolutionProviderRepositoryContract;
use Throwable;
class SolutionProviderRepository implements SolutionProviderRepositoryContract
{
/**
* @param array<int, ProvidesSolution> $solutionProviders
*/
protected Collection $solutionProviders;
/**
* @param array<int, ProvidesSolution> $solutionProviders
*/
public function __construct(array $solutionProviders = [])
{
$this->solutionProviders = Collection::make($solutionProviders);
}
public function registerSolutionProvider(string $solutionProviderClass): SolutionProviderRepositoryContract
{
$this->solutionProviders->push($solutionProviderClass);
return $this;
}
public function registerSolutionProviders(array $solutionProviderClasses): SolutionProviderRepositoryContract
{
$this->solutionProviders = $this->solutionProviders->merge($solutionProviderClasses);
return $this;
}
public function getSolutionsForThrowable(Throwable $throwable): array
{
$solutions = [];
if ($throwable instanceof Solution) {
$solutions[] = $throwable;
}
if ($throwable instanceof ProvidesSolution) {
$solutions[] = $throwable->getSolution();
}
/** @phpstan-ignore-next-line */
$providedSolutions = $this->solutionProviders
->filter(function (string $solutionClass) {
if (! in_array(HasSolutionsForThrowable::class, class_implements($solutionClass) ?: [])) {
return false;
}
if (in_array($solutionClass, config('ignition.ignored_solution_providers', []))) {
return false;
}
return true;
})
->map(fn (string $solutionClass) => app($solutionClass))
->filter(function (HasSolutionsForThrowable $solutionProvider) use ($throwable) {
try {
return $solutionProvider->canSolve($throwable);
} catch (Throwable $e) {
return false;
}
})
->map(function (HasSolutionsForThrowable $solutionProvider) use ($throwable) {
try {
return $solutionProvider->getSolutions($throwable);
} catch (Throwable $e) {
return [];
}
})
->flatten()
->toArray();
return array_merge($solutions, $providedSolutions);
}
public function getSolutionForClass(string $solutionClass): ?Solution
{
if (! class_exists($solutionClass)) {
return null;
}
if (! in_array(Solution::class, class_implements($solutionClass) ?: [])) {
return null;
}
return app($solutionClass);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Database\QueryException;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Solutions\RunMigrationsSolution;
use Throwable;
class TableNotFoundSolutionProvider implements HasSolutionsForThrowable
{
/**
* See https://dev.mysql.com/doc/refman/8.0/en/server-error-reference.html#error_er_bad_table_error.
*/
const MYSQL_BAD_TABLE_CODE = '42S02';
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof QueryException) {
return false;
}
return $this->isBadTableErrorCode($throwable->getCode());
}
protected function isBadTableErrorCode(string $code): bool
{
return $code === static::MYSQL_BAD_TABLE_CODE;
}
public function getSolutions(Throwable $throwable): array
{
return [new RunMigrationsSolution('A table was not found')];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Livewire\Exceptions\MethodNotFoundException;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Solutions\SuggestLivewireMethodNameSolution;
use Spatie\LaravelIgnition\Support\LivewireComponentParser;
use Throwable;
class UndefinedLivewireMethodSolutionProvider implements HasSolutionsForThrowable
{
public function canSolve(Throwable $throwable): bool
{
return $throwable instanceof MethodNotFoundException;
}
public function getSolutions(Throwable $throwable): array
{
['methodName' => $methodName, 'component' => $component] = $this->getMethodAndComponent($throwable);
if ($methodName === null || $component === null) {
return [];
}
$parsed = LivewireComponentParser::create($component);
return $parsed->getMethodNamesLike($methodName)
->map(function (string $suggested) use ($parsed, $methodName) {
return new SuggestLivewireMethodNameSolution(
$methodName,
$parsed->getComponentClass(),
$suggested
);
})
->toArray();
}
/** @return array<string, string|null> */
protected function getMethodAndComponent(Throwable $throwable): array
{
preg_match_all('/\[([\d\w\-_]*)\]/m', $throwable->getMessage(), $matches, PREG_SET_ORDER);
return [
'methodName' => $matches[0][1] ?? null,
'component' => $matches[1][1] ?? null,
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Livewire\Exceptions\PropertyNotFoundException;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Solutions\SuggestLivewirePropertyNameSolution;
use Spatie\LaravelIgnition\Support\LivewireComponentParser;
use Throwable;
class UndefinedLivewirePropertySolutionProvider implements HasSolutionsForThrowable
{
public function canSolve(Throwable $throwable): bool
{
return $throwable instanceof PropertyNotFoundException;
}
public function getSolutions(Throwable $throwable): array
{
['variable' => $variable, 'component' => $component] = $this->getMethodAndComponent($throwable);
if ($variable === null || $component === null) {
return [];
}
$parsed = LivewireComponentParser::create($component);
return $parsed->getPropertyNamesLike($variable)
->map(function (string $suggested) use ($parsed, $variable) {
return new SuggestLivewirePropertyNameSolution(
$variable,
$parsed->getComponentClass(),
'$'.$suggested
);
})
->toArray();
}
/**
* @param \Throwable $throwable
*
* @return array<string, string|null>
*/
protected function getMethodAndComponent(Throwable $throwable): array
{
preg_match_all('/\[([\d\w\-_\$]*)\]/m', $throwable->getMessage(), $matches, PREG_SET_ORDER, 0);
return [
'variable' => $matches[0][1] ?? null,
'component' => $matches[1][1] ?? null,
];
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\Ignition\Contracts\Solution;
use Spatie\LaravelIgnition\Exceptions\ViewException;
use Spatie\LaravelIgnition\Solutions\MakeViewVariableOptionalSolution;
use Spatie\LaravelIgnition\Solutions\SuggestCorrectVariableNameSolution;
use Throwable;
class UndefinedViewVariableSolutionProvider implements HasSolutionsForThrowable
{
protected string $variableName;
protected string $viewFile;
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof ViewException) {
return false;
}
return $this->getNameAndView($throwable) !== null;
}
public function getSolutions(Throwable $throwable): array
{
$solutions = [];
/** @phpstan-ignore-next-line */
extract($this->getNameAndView($throwable));
if (! isset($variableName)) {
return [];
}
if (isset($viewFile)) {
/** @phpstan-ignore-next-line */
$solutions = $this->findCorrectVariableSolutions($throwable, $variableName, $viewFile);
$solutions[] = $this->findOptionalVariableSolution($variableName, $viewFile);
}
return $solutions;
}
/**
* @param \Spatie\LaravelIgnition\Exceptions\ViewException $throwable
* @param string $variableName
* @param string $viewFile
*
* @return array<int, \Spatie\Ignition\Contracts\Solution>
*/
protected function findCorrectVariableSolutions(
ViewException $throwable,
string $variableName,
string $viewFile
): array {
return collect($throwable->getViewData())
->map(function ($value, $key) use ($variableName) {
similar_text($variableName, $key, $percentage);
return ['match' => $percentage, 'value' => $value];
})
->sortByDesc('match')
->filter(fn ($var) => $var['match'] > 40)
->keys()
->map(fn ($suggestion) => new SuggestCorrectVariableNameSolution($variableName, $viewFile, $suggestion))
->map(function ($solution) {
return $solution->isRunnable()
? $solution
: BaseSolution::create($solution->getSolutionTitle())
->setSolutionDescription($solution->getSolutionDescription());
})
->toArray();
}
protected function findOptionalVariableSolution(string $variableName, string $viewFile): Solution
{
$optionalSolution = new MakeViewVariableOptionalSolution($variableName, $viewFile);
return $optionalSolution->isRunnable()
? $optionalSolution
: BaseSolution::create($optionalSolution->getSolutionTitle())
->setSolutionDescription($optionalSolution->getSolutionDescription());
}
/**
* @param \Throwable $throwable
*
* @return array<string, string>|null
*/
protected function getNameAndView(Throwable $throwable): ?array
{
$pattern = '/Undefined variable:? (.*?) \(View: (.*?)\)/';
preg_match($pattern, $throwable->getMessage(), $matches);
if (count($matches) === 3) {
[, $variableName, $viewFile] = $matches;
$variableName = ltrim($variableName, '$');
return compact('variableName', 'viewFile');
}
return null;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Database\QueryException;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Solutions\SuggestUsingMariadbDatabaseSolution;
use Throwable;
class UnknownMariadbCollationSolutionProvider implements HasSolutionsForThrowable
{
const MYSQL_UNKNOWN_COLLATION_CODE = 1273;
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof QueryException) {
return false;
}
if ($throwable->getCode() !== self::MYSQL_UNKNOWN_COLLATION_CODE) {
return false;
}
return str_contains(
$throwable->getMessage(),
'Unknown collation: \'utf8mb4_uca1400_ai_ci\''
);
}
public function getSolutions(Throwable $throwable): array
{
return [new SuggestUsingMariadbDatabaseSolution()];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Database\QueryException;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Solutions\SuggestUsingMysql8DatabaseSolution;
use Throwable;
class UnknownMysql8CollationSolutionProvider implements HasSolutionsForThrowable
{
const MYSQL_UNKNOWN_COLLATION_CODE = 1273;
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof QueryException) {
return false;
}
if ($throwable->getCode() !== self::MYSQL_UNKNOWN_COLLATION_CODE) {
return false;
}
return str_contains(
$throwable->getMessage(),
'Unknown collation: \'utf8mb4_0900_ai_ci\''
);
}
public function getSolutions(Throwable $throwable): array
{
return [new SuggestUsingMysql8DatabaseSolution()];
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use BadMethodCallException;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Validation\Validator;
use ReflectionClass;
use ReflectionMethod;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Support\StringComparator;
use Throwable;
class UnknownValidationSolutionProvider implements HasSolutionsForThrowable
{
protected const REGEX = '/Illuminate\\\\Validation\\\\Validator::(?P<method>validate(?!(Attribute|UsingCustomRule))[A-Z][a-zA-Z]+)/m';
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof BadMethodCallException) {
return false;
}
return ! is_null($this->getMethodFromExceptionMessage($throwable->getMessage()));
}
public function getSolutions(Throwable $throwable): array
{
return [
BaseSolution::create()
->setSolutionTitle('Unknown Validation Rule')
->setSolutionDescription($this->getSolutionDescription($throwable)),
];
}
protected function getSolutionDescription(Throwable $throwable): string
{
$method = (string)$this->getMethodFromExceptionMessage($throwable->getMessage());
$possibleMethod = StringComparator::findSimilarText(
$this->getAvailableMethods()->toArray(),
$method
);
if (empty($possibleMethod)) {
return '';
}
$rule = Str::snake(str_replace('validate', '', $possibleMethod));
return "Did you mean `{$rule}` ?";
}
protected function getMethodFromExceptionMessage(string $message): ?string
{
if (! preg_match(self::REGEX, $message, $matches)) {
return null;
}
return $matches['method'];
}
protected function getAvailableMethods(): Collection
{
$class = new ReflectionClass(Validator::class);
$extensions = Collection::make((app('validator')->make([], []))->extensions)
->keys()
->map(fn (string $extension) => 'validate'.Str::studly($extension));
return Collection::make($class->getMethods())
->filter(fn (ReflectionMethod $method) => preg_match('/(validate(?!(Attribute|UsingCustomRule))[A-Z][a-zA-Z]+)/', $method->name))
->map(fn (ReflectionMethod $method) => $method->name)
->merge($extensions);
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionProviders;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\View;
use InvalidArgumentException;
use Spatie\Ignition\Contracts\BaseSolution;
use Spatie\Ignition\Contracts\HasSolutionsForThrowable;
use Spatie\LaravelIgnition\Exceptions\ViewException;
use Spatie\LaravelIgnition\Support\StringComparator;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Throwable;
class ViewNotFoundSolutionProvider implements HasSolutionsForThrowable
{
protected const REGEX = '/View \[(.*)\] not found/m';
public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof InvalidArgumentException && ! $throwable instanceof ViewException) {
return false;
}
return (bool)preg_match(self::REGEX, $throwable->getMessage(), $matches);
}
public function getSolutions(Throwable $throwable): array
{
preg_match(self::REGEX, $throwable->getMessage(), $matches);
$missingView = $matches[1] ?? null;
$suggestedView = $this->findRelatedView($missingView);
if ($suggestedView) {
return [
BaseSolution::create()
->setSolutionTitle("{$missingView} was not found.")
->setSolutionDescription("Did you mean `{$suggestedView}`?"),
];
}
return [
BaseSolution::create()
->setSolutionTitle("{$missingView} was not found.")
->setSolutionDescription('Are you sure the view exists and is a `.blade.php` file?'),
];
}
protected function findRelatedView(string $missingView): ?string
{
$views = $this->getAllViews();
return StringComparator::findClosestMatch($views, $missingView);
}
/** @return array<int, string> */
protected function getAllViews(): array
{
/** @var \Illuminate\View\FileViewFinder $fileViewFinder */
$fileViewFinder = View::getFinder();
$extensions = $fileViewFinder->getExtensions();
$viewsForHints = collect($fileViewFinder->getHints())
->flatMap(function ($paths, string $namespace) use ($extensions) {
$paths = Arr::wrap($paths);
return collect($paths)
->flatMap(fn (string $path) => $this->getViewsInPath($path, $extensions))
->map(fn (string $view) => "{$namespace}::{$view}")
->toArray();
});
$viewsForViewPaths = collect($fileViewFinder->getPaths())
->flatMap(fn (string $path) => $this->getViewsInPath($path, $extensions));
return $viewsForHints->merge($viewsForViewPaths)->toArray();
}
/**
* @param string $path
* @param array<int, string> $extensions
*
* @return array<int, string>
*/
protected function getViewsInPath(string $path, array $extensions): array
{
$filePatterns = array_map(fn (string $extension) => "*.{$extension}", $extensions);
$extensionsWithDots = array_map(fn (string $extension) => ".{$extension}", $extensions);
$files = (new Finder())
->in($path)
->files();
foreach ($filePatterns as $filePattern) {
$files->name($filePattern);
}
$views = [];
foreach ($files as $file) {
if ($file instanceof SplFileInfo) {
$view = $file->getRelativePathname();
$view = str_replace($extensionsWithDots, '', $view);
$view = str_replace('/', '.', $view);
$views[] = $view;
}
}
return $views;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Spatie\LaravelIgnition\Solutions\SolutionTransformers;
use Spatie\Ignition\Contracts\RunnableSolution;
use Spatie\Ignition\Solutions\SolutionTransformer;
use Spatie\LaravelIgnition\Http\Controllers\ExecuteSolutionController;
use Throwable;
class LaravelSolutionTransformer extends SolutionTransformer
{
/** @return array<string|mixed> */
public function toArray(): array
{
$baseProperties = parent::toArray();
if (! $this->isRunnable()) {
return $baseProperties;
}
/** @var RunnableSolution $solution Type shenanigans */
$solution = $this->solution;
$runnableProperties = [
'is_runnable' => true,
'action_description' => $solution->getSolutionActionDescription(),
'run_button_text' => $solution->getRunButtonText(),
'execute_endpoint' => $this->executeEndpoint(),
'run_parameters' => $solution->getRunParameters(),
];
return array_merge($baseProperties, $runnableProperties);
}
protected function isRunnable(): bool
{
if (! $this->solution instanceof RunnableSolution) {
return false;
}
if (! $this->executeEndpoint()) {
return false;
}
return true;
}
protected function executeEndpoint(): ?string
{
try {
// The action class needs to be prefixed with a `\` to Laravel from trying
// to add its own global namespace from RouteServiceProvider::$namespace.
return action('\\'.ExecuteSolutionController::class);
} catch (Throwable $exception) {
report($exception);
return null;
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Spatie\Ignition\Contracts\Solution;
class SuggestCorrectVariableNameSolution implements Solution
{
protected ?string $variableName;
protected ?string $viewFile;
protected ?string $suggested;
public function __construct(string $variableName = null, string $viewFile = null, string $suggested = null)
{
$this->variableName = $variableName;
$this->viewFile = $viewFile;
$this->suggested = $suggested;
}
public function getSolutionTitle(): string
{
return 'Possible typo $'.$this->variableName;
}
public function getDocumentationLinks(): array
{
return [];
}
public function getSolutionDescription(): string
{
return "Did you mean `$$this->suggested`?";
}
public function isRunnable(): bool
{
return false;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Spatie\Ignition\Contracts\Solution;
class SuggestImportSolution implements Solution
{
protected string $class;
public function __construct(string $class = '')
{
$this->class = $class;
}
public function getSolutionTitle(): string
{
return 'A class import is missing';
}
public function getSolutionDescription(): string
{
return 'You have a missing class import. Try importing this class: `'.$this->class.'`.';
}
public function getDocumentationLinks(): array
{
return [];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Spatie\Ignition\Contracts\Solution;
class SuggestLivewireMethodNameSolution implements Solution
{
public function __construct(
protected string $methodName,
protected string $componentClass,
protected string $suggested
) {
}
public function getSolutionTitle(): string
{
return "Possible typo `{$this->componentClass}::{$this->methodName}`";
}
public function getDocumentationLinks(): array
{
return [];
}
public function getSolutionDescription(): string
{
return "Did you mean `{$this->componentClass}::{$this->suggested}`?";
}
public function isRunnable(): bool
{
return false;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Spatie\Ignition\Contracts\Solution;
class SuggestLivewirePropertyNameSolution implements Solution
{
public function __construct(
protected string $variableName,
protected string $componentClass,
protected string $suggested,
) {
}
public function getSolutionTitle(): string
{
return "Possible typo {$this->variableName}";
}
public function getDocumentationLinks(): array
{
return [];
}
public function getSolutionDescription(): string
{
return "Did you mean `$this->suggested`?";
}
public function isRunnable(): bool
{
return false;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Spatie\Ignition\Contracts\Solution;
class SuggestUsingCorrectDbNameSolution implements Solution
{
public function getSolutionTitle(): string
{
return 'Database name seems incorrect';
}
public function getSolutionDescription(): string
{
$defaultDatabaseName = env('DB_DATABASE');
return "You're using the default database name `$defaultDatabaseName`. This database does not exist.\n\nEdit the `.env` file and use the correct database name in the `DB_DATABASE` key.";
}
/** @return array<string, string> */
public function getDocumentationLinks(): array
{
return [
'Database: Getting Started docs' => 'https://laravel.com/docs/master/database#configuration',
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Spatie\Ignition\Contracts\Solution;
class SuggestUsingMariadbDatabaseSolution implements Solution
{
public function getSolutionTitle(): string
{
return 'Database is not a MariaDB database';
}
public function getSolutionDescription(): string
{
return "Laravel 11 changed the default collation for MySQL and MariaDB. It seems you are trying to use the MariaDB collation `utf8mb4_uca1400_ai_ci` with a MySQL database.\n\nEdit the `.env` file and use the correct database in the `DB_CONNECTION` key.";
}
/** @return array<string, string> */
public function getDocumentationLinks(): array
{
return [
'Database: Getting Started docs' => 'https://laravel.com/docs/master/database#configuration',
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Spatie\Ignition\Contracts\Solution;
class SuggestUsingMysql8DatabaseSolution implements Solution
{
public function getSolutionTitle(): string
{
return 'Database is not a MySQL 8 database';
}
public function getSolutionDescription(): string
{
return "Laravel 11 changed the default collation for MySQL and MariaDB. It seems you are trying to use the MySQL 8 collation `utf8mb4_0900_ai_ci` with a MariaDB or MySQL 5.7 database.\n\nEdit the `.env` file and use the correct database in the `DB_CONNECTION` key.";
}
/** @return array<string, string> */
public function getDocumentationLinks(): array
{
return [
'Database: Getting Started docs' => 'https://laravel.com/docs/master/database#configuration',
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Spatie\LaravelIgnition\Solutions;
use Illuminate\Support\Str;
use Spatie\Ignition\Contracts\RunnableSolution;
class UseDefaultValetDbCredentialsSolution implements RunnableSolution
{
public function getSolutionActionDescription(): string
{
return 'Pressing the button will change `DB_USER` and `DB_PASSWORD` in your `.env` file.';
}
public function getRunButtonText(): string
{
return 'Use default Valet credentials';
}
public function getSolutionTitle(): string
{
return 'Could not connect to database';
}
public function run(array $parameters = []): void
{
if (! file_exists(base_path('.env'))) {
return;
}
$this->ensureLineExists('DB_USERNAME', 'root');
$this->ensureLineExists('DB_PASSWORD', '');
}
protected function ensureLineExists(string $key, string $value): void
{
$envPath = base_path('.env');
$envLines = array_map(fn (string $envLine) => Str::startsWith($envLine, $key)
? "{$key}={$value}".PHP_EOL
: $envLine, file($envPath) ?: []);
file_put_contents($envPath, implode('', $envLines));
}
public function getRunParameters(): array
{
return [];
}
public function getDocumentationLinks(): array
{
return [
'Valet documentation' => 'https://laravel.com/docs/master/valet',
];
}
public function getSolutionDescription(): string
{
return 'You seem to be using Valet, but the .env file does not contain the right default database credentials.';
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Spatie\LaravelIgnition\Support\Composer;
interface Composer
{
/** @return array<string, mixed> */
public function getClassMap(): array;
/** @return array<string, mixed> */
public function getPrefixes(): array;
/** @return array<string, mixed> */
public function getPrefixesPsr4(): array;
}

View File

@@ -0,0 +1,127 @@
<?php
namespace Spatie\LaravelIgnition\Support\Composer;
use function app_path;
use function base_path;
use Illuminate\Support\Str;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class ComposerClassMap
{
/** @var \Spatie\LaravelIgnition\Support\Composer\Composer */
protected object $composer;
protected string $basePath;
public function __construct(?string $autoloaderPath = null)
{
$autoloaderPath = $autoloaderPath ?? base_path('/vendor/autoload.php');
$this->composer = file_exists($autoloaderPath)
? require $autoloaderPath
: new FakeComposer();
$this->basePath = app_path();
}
/** @return array<string, string> */
public function listClasses(): array
{
$classes = $this->composer->getClassMap();
return array_merge($classes, $this->listClassesInPsrMaps());
}
public function searchClassMap(string $missingClass): ?string
{
foreach ($this->composer->getClassMap() as $fqcn => $file) {
$basename = basename($file, '.php');
if ($basename === $missingClass) {
return $fqcn;
}
}
return null;
}
/** @return array<string, mixed> */
public function listClassesInPsrMaps(): array
{
// TODO: This is incorrect. Doesnt list all fqcns. Need to parse namespace? e.g. App\LoginController is wrong
$prefixes = array_merge(
$this->composer->getPrefixes(),
$this->composer->getPrefixesPsr4()
);
$classes = [];
foreach ($prefixes as $namespace => $directories) {
foreach ($directories as $directory) {
if (file_exists($directory)) {
$files = (new Finder)
->in($directory)
->files()
->name('*.php');
foreach ($files as $file) {
if ($file instanceof SplFileInfo) {
$fqcn = $this->getFullyQualifiedClassNameFromFile($namespace, $file);
$classes[$fqcn] = $file->getRelativePathname();
}
}
}
}
}
return $classes;
}
public function searchPsrMaps(string $missingClass): ?string
{
$prefixes = array_merge(
$this->composer->getPrefixes(),
$this->composer->getPrefixesPsr4()
);
foreach ($prefixes as $namespace => $directories) {
foreach ($directories as $directory) {
if (file_exists($directory)) {
$files = (new Finder)
->in($directory)
->files()
->name('*.php');
foreach ($files as $file) {
if ($file instanceof SplFileInfo) {
$basename = basename($file->getRelativePathname(), '.php');
if ($basename === $missingClass) {
return $namespace . basename($file->getRelativePathname(), '.php');
}
}
}
}
}
}
return null;
}
protected function getFullyQualifiedClassNameFromFile(string $rootNamespace, SplFileInfo $file): string
{
$class = trim(str_replace($this->basePath, '', (string)$file->getRealPath()), DIRECTORY_SEPARATOR);
$class = str_replace(
[DIRECTORY_SEPARATOR, 'App\\'],
['\\', app()->getNamespace()],
ucfirst(Str::replaceLast('.php', '', $class))
);
return $rootNamespace . $class;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Spatie\LaravelIgnition\Support\Composer;
class FakeComposer implements Composer
{
/** @return array<string, mixed> */
public function getClassMap(): array
{
return [];
}
/** @return array<string, mixed> */
public function getPrefixes(): array
{
return [];
}
/** @return array<string, mixed> */
public function getPrefixesPsr4(): array
{
return [];
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace Spatie\LaravelIgnition\Support;
use InvalidArgumentException;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\Logger;
use Monolog\LogRecord;
use Spatie\FlareClient\Flare;
use Spatie\FlareClient\Report;
use Throwable;
class FlareLogHandler extends AbstractProcessingHandler
{
protected Flare $flare;
protected SentReports $sentReports;
protected int $minimumReportLogLevel;
public function __construct(Flare $flare, SentReports $sentReports, $level = Level::Debug, $bubble = true)
{
$this->flare = $flare;
$this->minimumReportLogLevel = Level::Error->value;
$this->sentReports = $sentReports;
parent::__construct($level, $bubble);
}
public function setMinimumReportLogLevel(int $level): void
{
if (! in_array($level, Level::VALUES)) {
throw new InvalidArgumentException('The given minimum log level is not supported.');
}
$this->minimumReportLogLevel = $level;
}
protected function write(LogRecord $record): void
{
if (! $this->shouldReport($record->toArray())) {
return;
}
if ($this->hasException($record->toArray())) {
$report = $this->flare->report($record['context']['exception']);
if ($report) {
$this->sentReports->add($report);
}
return;
}
if (config('flare.send_logs_as_events')) {
if ($this->hasValidLogLevel($record->toArray())) {
$this->flare->reportMessage(
$record['message'],
'Log ' . Logger::toMonologLevel($record['level'])->getName(),
function (Report $flareReport) use ($record) {
foreach ($record['context'] as $key => $value) {
$flareReport->context($key, $value);
}
}
);
}
}
}
/**
* @param array<string, mixed> $report
*
* @return bool
*/
protected function shouldReport(array $report): bool
{
if (! config('flare.key')) {
return false;
}
return $this->hasException($report) || $this->hasValidLogLevel($report);
}
/**
* @param array<string, mixed> $report
*
* @return bool
*/
protected function hasException(array $report): bool
{
$context = $report['context'];
return isset($context['exception']) && $context['exception'] instanceof Throwable;
}
/**
* @param array<string, mixed> $report
*
* @return bool
*/
protected function hasValidLogLevel(array $report): bool
{
return $report['level'] >= $this->minimumReportLogLevel;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Spatie\LaravelIgnition\Support;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Spatie\LaravelIgnition\Exceptions\ViewException;
use Throwable;
class LaravelDocumentationLinkFinder
{
public function findLinkForThrowable(Throwable $throwable): ?string
{
if ($throwable instanceof ViewException) {
$throwable = $throwable->getPrevious();
}
$majorVersion = LaravelVersion::major();
if (str_contains($throwable->getMessage(), Collection::class)) {
return "https://laravel.com/docs/{$majorVersion}.x/collections#available-methods";
}
$type = $this->getType($throwable);
if (! $type) {
return null;
}
return match ($type) {
'Auth' => "https://laravel.com/docs/{$majorVersion}.x/authentication",
'Broadcasting' => "https://laravel.com/docs/{$majorVersion}.x/broadcasting",
'Container' => "https://laravel.com/docs/{$majorVersion}.x/container",
'Database' => "https://laravel.com/docs/{$majorVersion}.x/eloquent",
'Pagination' => "https://laravel.com/docs/{$majorVersion}.x/pagination",
'Queue' => "https://laravel.com/docs/{$majorVersion}.x/queues",
'Routing' => "https://laravel.com/docs/{$majorVersion}.x/routing",
'Session' => "https://laravel.com/docs/{$majorVersion}.x/session",
'Validation' => "https://laravel.com/docs/{$majorVersion}.x/validation",
'View' => "https://laravel.com/docs/{$majorVersion}.x/views",
default => null,
};
}
protected function getType(?Throwable $throwable): ?string
{
if (! $throwable) {
return null;
}
if (str_contains($throwable::class, 'Illuminate')) {
return Str::between($throwable::class, 'Illuminate\\', '\\');
}
if (str_contains($throwable->getMessage(), 'Illuminate')) {
return explode('\\', Str::between($throwable->getMessage(), 'Illuminate\\', '\\'))[0];
}
return null;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Spatie\LaravelIgnition\Support;
class LaravelVersion
{
public static function major(): string
{
return explode('.', app()->version())[0];
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Spatie\LaravelIgnition\Support;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\LivewireManager;
use ReflectionClass;
use ReflectionMethod;
use ReflectionProperty;
class LivewireComponentParser
{
protected string $componentClass;
protected ReflectionClass $reflectionClass;
public static function create(string $componentAlias): self
{
return new self($componentAlias);
}
public function __construct(protected string $componentAlias)
{
$this->componentClass = app(LivewireManager::class)->getClass($this->componentAlias);
$this->reflectionClass = new ReflectionClass($this->componentClass);
}
public function getComponentClass(): string
{
return $this->componentClass;
}
public function getPropertyNamesLike(string $similar): Collection
{
$properties = collect($this->reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC))
// @phpstan-ignore-next-line
->reject(fn (ReflectionProperty $reflectionProperty) => $reflectionProperty->class !== $this->reflectionClass->name)
->map(fn (ReflectionProperty $reflectionProperty) => $reflectionProperty->name);
$computedProperties = collect($this->reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC))
// @phpstan-ignore-next-line
->reject(fn (ReflectionMethod $reflectionMethod) => $reflectionMethod->class !== $this->reflectionClass->name)
->filter(fn (ReflectionMethod $reflectionMethod) => str_starts_with($reflectionMethod->name, 'get') && str_ends_with($reflectionMethod->name, 'Property'))
->map(fn (ReflectionMethod $reflectionMethod) => lcfirst(Str::of($reflectionMethod->name)->after('get')->before('Property')));
return $this->filterItemsBySimilarity(
$properties->merge($computedProperties),
$similar
);
}
public function getMethodNamesLike(string $similar): Collection
{
$methods = collect($this->reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC))
// @phpstan-ignore-next-line
->reject(fn (ReflectionMethod $reflectionMethod) => $reflectionMethod->class !== $this->reflectionClass->name)
->map(fn (ReflectionMethod $reflectionMethod) => $reflectionMethod->name);
return $this->filterItemsBySimilarity($methods, $similar);
}
protected function filterItemsBySimilarity(Collection $items, string $similar): Collection
{
return $items
->map(function (string $name) use ($similar) {
similar_text($similar, $name, $percentage);
return ['match' => $percentage, 'value' => $name];
})
->sortByDesc('match')
->filter(function (array $item) {
return $item['match'] > 40;
})
->map(function (array $item) {
return $item['value'];
})
->values();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Spatie\LaravelIgnition\Support;
class RunnableSolutionsGuard
{
/**
* Check if runnable solutions are allowed based on the current
* environment and config.
*
* @return bool
*/
public static function check(): bool
{
if (! config('app.debug')) {
// Never run solutions in when debug mode is not enabled.
return false;
}
if (config('ignition.enable_runnable_solutions') !== null) {
// Allow enabling or disabling runnable solutions regardless of environment
// if the IGNITION_ENABLE_RUNNABLE_SOLUTIONS env var is explicitly set.
return config('ignition.enable_runnable_solutions');
}
if (! app()->environment('local') && ! app()->environment('development')) {
// Never run solutions on non-local environments. This avoids exposing
// applications that are somehow APP_ENV=production with APP_DEBUG=true.
return false;
}
return config('app.debug');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Spatie\LaravelIgnition\Support;
use Illuminate\Support\Arr;
use Spatie\FlareClient\Report;
class SentReports
{
/** @var array<int, Report> */
protected array $reports = [];
public function add(Report $report): self
{
$this->reports[] = $report;
return $this;
}
/** @return array<int, Report> */
public function all(): array
{
return $this->reports;
}
/** @return array<int, string> */
public function uuids(): array
{
return array_map(fn (Report $report) => $report->trackingUuid(), $this->reports);
}
/** @return array<int, string> */
public function urls(): array
{
return array_map(function (string $trackingUuid) {
return "https://flareapp.io/tracked-occurrence/{$trackingUuid}";
}, $this->uuids());
}
public function latestUuid(): ?string
{
return Arr::last($this->reports)?->trackingUuid();
}
public function latestUrl(): ?string
{
return Arr::last($this->urls());
}
public function clear(): void
{
$this->reports = [];
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Spatie\LaravelIgnition\Support;
use Illuminate\Support\Collection;
class StringComparator
{
/**
* @param array<int|string, string> $strings
* @param string $input
* @param int $sensitivity
*
* @return string|null
*/
public static function findClosestMatch(array $strings, string $input, int $sensitivity = 4): ?string
{
$closestDistance = -1;
$closestMatch = null;
foreach ($strings as $string) {
$levenshteinDistance = levenshtein($input, $string);
if ($levenshteinDistance === 0) {
$closestMatch = $string;
$closestDistance = 0;
break;
}
if ($levenshteinDistance <= $closestDistance || $closestDistance < 0) {
$closestMatch = $string;
$closestDistance = $levenshteinDistance;
}
}
if ($closestDistance <= $sensitivity) {
return $closestMatch;
}
return null;
}
/**
* @param array<int, string> $strings
* @param string $input
*
* @return string|null
*/
public static function findSimilarText(array $strings, string $input): ?string
{
if (empty($strings)) {
return null;
}
return Collection::make($strings)
->sortByDesc(function (string $string) use ($input) {
similar_text($input, $string, $percentage);
return $percentage;
})
->first();
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace Spatie\LaravelIgnition\Views;
use Illuminate\View\Compilers\BladeCompiler;
use Throwable;
class BladeSourceMapCompiler
{
protected BladeCompiler $bladeCompiler;
public function __construct()
{
$this->bladeCompiler = app('blade.compiler');
}
public function detectLineNumber(string $filename, int $compiledLineNumber): int
{
$map = $this->compileSourcemap((string)file_get_contents($filename));
return $this->findClosestLineNumberMapping($map, $compiledLineNumber);
}
protected function compileSourcemap(string $value): string
{
try {
$value = $this->addEchoLineNumbers($value);
$value = $this->addStatementLineNumbers($value);
$value = $this->addBladeComponentLineNumbers($value);
$value = $this->bladeCompiler->compileString($value);
return $this->trimEmptyLines($value);
} catch (Throwable $e) {
report($e);
return $value;
}
}
protected function addEchoLineNumbers(string $value): string
{
$echoPairs = [['{{', '}}'], ['{{{', '}}}'], ['{!!', '!!}']];
foreach ($echoPairs as $pair) {
// Matches {{ $value }}, {!! $value !!} and {{{ $value }}} depending on $pair
$pattern = sprintf('/(@)?%s\s*(.+?)\s*%s(\r?\n)?/s', $pair[0], $pair[1]);
if (preg_match_all($pattern, $value, $matches, PREG_OFFSET_CAPTURE)) {
foreach (array_reverse($matches[0]) as $match) {
$position = mb_strlen(substr($value, 0, $match[1]));
$value = $this->insertLineNumberAtPosition($position, $value);
}
}
}
return $value;
}
protected function addStatementLineNumbers(string $value): string
{
// Matches @bladeStatements() like @if, @component(...), @etc;
$shouldInsertLineNumbers = preg_match_all(
'/\B@(@?\w+(?:::\w+)?)([ \t]*)(\( ( (?>[^()]+) | (?3) )* \))?/x',
$value,
$matches,
PREG_OFFSET_CAPTURE
);
if ($shouldInsertLineNumbers) {
foreach (array_reverse($matches[0]) as $match) {
$position = mb_strlen(substr($value, 0, $match[1]));
$value = $this->insertLineNumberAtPosition($position, $value);
}
}
return $value;
}
protected function addBladeComponentLineNumbers(string $value): string
{
// Matches the start of `<x-blade-component`
$shouldInsertLineNumbers = preg_match_all(
'/<\s*x[-:]([\w\-:.]*)/mx',
$value,
$matches,
PREG_OFFSET_CAPTURE
);
if ($shouldInsertLineNumbers) {
foreach (array_reverse($matches[0]) as $match) {
$position = mb_strlen(substr($value, 0, $match[1]));
$value = $this->insertLineNumberAtPosition($position, $value);
}
}
return $value;
}
protected function insertLineNumberAtPosition(int $position, string $value): string
{
$before = mb_substr($value, 0, $position);
$lineNumber = count(explode("\n", $before));
return mb_substr($value, 0, $position)."|---LINE:{$lineNumber}---|".mb_substr($value, $position);
}
protected function trimEmptyLines(string $value): string
{
$value = preg_replace('/^\|---LINE:([0-9]+)---\|$/m', '', $value);
return ltrim((string)$value, PHP_EOL);
}
protected function findClosestLineNumberMapping(string $map, int $compiledLineNumber): int
{
$map = explode("\n", $map);
// Max 20 lines between compiled and source line number.
// Blade components can span multiple lines and the compiled line number is often
// a couple lines below the source-mapped `<x-component>` code.
$maxDistance = 20;
$pattern = '/\|---LINE:(?P<line>[0-9]+)---\|/m';
$lineNumberToCheck = $compiledLineNumber - 1;
while (true) {
if ($lineNumberToCheck < $compiledLineNumber - $maxDistance) {
// Something wrong. Return the $compiledLineNumber (unless it's out of range)
return min($compiledLineNumber, count($map));
}
if (preg_match($pattern, $map[$lineNumberToCheck] ?? '', $matches)) {
return (int)$matches['line'];
}
$lineNumberToCheck--;
}
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace Spatie\LaravelIgnition\Views;
use Exception;
use Illuminate\Contracts\View\Engine;
use Illuminate\Foundation\Application;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\View\Engines\PhpEngine;
use Illuminate\View\ViewException;
use ReflectionClass;
use ReflectionProperty;
use Spatie\Ignition\Contracts\ProvidesSolution;
use Spatie\LaravelIgnition\Exceptions\ViewException as IgnitionViewException;
use Spatie\LaravelIgnition\Exceptions\ViewExceptionWithSolution;
use Throwable;
class ViewExceptionMapper
{
protected Engine $compilerEngine;
protected BladeSourceMapCompiler $bladeSourceMapCompiler;
protected array $knownPaths;
public function __construct(BladeSourceMapCompiler $bladeSourceMapCompiler)
{
$resolver = app('view.engine.resolver');
$this->compilerEngine = $resolver->resolve('blade');
$this->bladeSourceMapCompiler = $bladeSourceMapCompiler;
}
public function map(ViewException $viewException): IgnitionViewException
{
$baseException = $this->getRealException($viewException);
if ($baseException instanceof IgnitionViewException) {
return $baseException;
}
preg_match('/\(View: (?P<path>.*?)\)/', $viewException->getMessage(), $matches);
$compiledViewPath = $matches['path'];
$exception = $this->createException($baseException);
if ($baseException instanceof ProvidesSolution) {
/** @var ViewExceptionWithSolution $exception */
$exception->setSolution($baseException->getSolution());
}
$this->modifyViewsInTrace($exception);
$exception->setView($compiledViewPath);
$exception->setViewData($this->getViewData($exception));
return $exception;
}
protected function createException(Throwable $baseException): IgnitionViewException
{
$viewExceptionClass = $baseException instanceof ProvidesSolution
? ViewExceptionWithSolution::class
: IgnitionViewException::class;
$viewFile = $this->findCompiledView($baseException->getFile());
$file = $viewFile ?? $baseException->getFile();
$line = $viewFile ? $this->getBladeLineNumber($file, $baseException->getLine()) : $baseException->getLine();
return new $viewExceptionClass(
$baseException->getMessage(),
0,
1,
$file,
$line,
$baseException
);
}
protected function modifyViewsInTrace(IgnitionViewException $exception): void
{
$trace = Collection::make($exception->getPrevious()->getTrace())
->map(function ($trace) {
if ($originalPath = $this->findCompiledView(Arr::get($trace, 'file', ''))) {
$trace['file'] = $originalPath;
$trace['line'] = $this->getBladeLineNumber($trace['file'], $trace['line']);
}
return $trace;
})->toArray();
$traceProperty = new ReflectionProperty('Exception', 'trace');
$traceProperty->setAccessible(true);
$traceProperty->setValue($exception, $trace);
}
/**
* Look at the previous exceptions to find the original exception.
* This is usually the first Exception that is not a ViewException.
*/
protected function getRealException(Throwable $exception): Throwable
{
$rootException = $exception->getPrevious() ?? $exception;
while ($rootException instanceof ViewException && $rootException->getPrevious()) {
$rootException = $rootException->getPrevious();
}
return $rootException;
}
protected function findCompiledView(string $compiledPath): ?string
{
$this->knownPaths ??= $this->getKnownPaths();
return $this->knownPaths[$compiledPath] ?? null;
}
protected function getKnownPaths(): array
{
$compilerEngineReflection = new ReflectionClass($this->compilerEngine);
if (! $compilerEngineReflection->hasProperty('lastCompiled') && $compilerEngineReflection->hasProperty('engine')) {
$compilerEngine = $compilerEngineReflection->getProperty('engine');
$compilerEngine->setAccessible(true);
$compilerEngine = $compilerEngine->getValue($this->compilerEngine);
$lastCompiled = new ReflectionProperty($compilerEngine, 'lastCompiled');
$lastCompiled->setAccessible(true);
$lastCompiled = $lastCompiled->getValue($compilerEngine);
} else {
$lastCompiled = $compilerEngineReflection->getProperty('lastCompiled');
$lastCompiled->setAccessible(true);
$lastCompiled = $lastCompiled->getValue($this->compilerEngine);
}
$knownPaths = [];
foreach ($lastCompiled as $lastCompiledPath) {
$compiledPath = $this->compilerEngine->getCompiler()->getCompiledPath($lastCompiledPath);
$knownPaths[realpath($compiledPath ?? $lastCompiledPath)] = realpath($lastCompiledPath);
}
return $knownPaths;
}
protected function getBladeLineNumber(string $view, int $compiledLineNumber): int
{
return $this->bladeSourceMapCompiler->detectLineNumber($view, $compiledLineNumber);
}
protected function getViewData(Throwable $exception): array
{
foreach ($exception->getTrace() as $frame) {
if (Arr::get($frame, 'class') === PhpEngine::class) {
$data = Arr::get($frame, 'args.1', []);
return $this->filterViewData($data);
}
}
return [];
}
protected function filterViewData(array $data): array
{
// By default, Laravel views get two data keys:
// __env and app. We try to filter them out.
return array_filter($data, function ($value, $key) {
if ($key === 'app') {
return ! $value instanceof Application;
}
return $key !== '__env';
}, ARRAY_FILTER_USE_BOTH);
}
}

View File

@@ -0,0 +1,24 @@
<?php
use Spatie\LaravelIgnition\Renderers\ErrorPageRenderer;
if (! function_exists('ddd')) {
function ddd()
{
$args = func_get_args();
if (count($args) === 0) {
throw new Exception('You should pass at least 1 argument to `ddd`');
}
call_user_func_array('dump', $args);
$renderer = app()->make(ErrorPageRenderer::class);
$exception = new Exception('Dump, Die, Debug');
$renderer->render($exception);
die();
}
}

View File

@@ -0,0 +1,20 @@
<?php
use Illuminate\Support\Facades\Route;
use Spatie\LaravelIgnition\Http\Controllers\ExecuteSolutionController;
use Spatie\LaravelIgnition\Http\Controllers\HealthCheckController;
use Spatie\LaravelIgnition\Http\Controllers\UpdateConfigController;
use Spatie\LaravelIgnition\Http\Middleware\RunnableSolutionsEnabled;
Route::group([
'as' => 'ignition.',
'prefix' => config('ignition.housekeeping_endpoint_prefix'),
'middleware' => [RunnableSolutionsEnabled::class],
], function () {
Route::get('health-check', HealthCheckController::class)->name('healthCheck');
Route::post('execute-solution', ExecuteSolutionController::class)
->name('executeSolution');
Route::post('update-config', UpdateConfigController::class)->name('updateConfig');
});