diff --git a/AGENTS.md b/AGENTS.md index c914d5d08..540ca16c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,10 +195,21 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan - **Use `Sleep::usleep()` / `Sleep::sleep()` for delays in source code** — `Sleep` is fakeable in tests. Use raw `sleep()` / `usleep()` only where real time must pass, such as test harnesses and external-process polling. - **Use `xxh128` for internal non-cryptographic hashing** — cache and context keys, content checksums, and change detection. It is faster than `sha256`, which is reserved for trust boundaries: stored credential digests, signatures, and anything an attacker gains by forging. Seed it when the hashed value comes from user input, as `SwooleStore` does for its physical table keys. - **Use immutable dates by default** — Hypervel defaults to `Hypervel\Support\CarbonImmutable`, including where Laravel uses mutable Carbon. Create public or application-configurable dates through the `Date` facade or date helpers, and use exact `CarbonImmutable` for framework-owned internal or held values. Type configurable Carbon boundaries as `CarbonInterface` and native or third-party boundaries as `DateTimeInterface`. Capture the return value of every date modifier whose result must persist. Use `Hypervel\Support\Carbon` only for explicit mutable opt-out or conversion behavior. -- **Use typed config getters and avoid duplicate defaults** — prefer `$config->string()`, `$config->integer()`, `$config->float()`, `$config->boolean()`, and `$config->array()` over `$config->get()` for values that cannot be null. Do not pass a code-level fallback when the framework or package config defines the key; missing or misspelled keys should fail loudly instead of silently using a second default. Framework and package defaults are shallow-merged with application config. `mergeableOptions()` is only for named groups such as connections or stores: application entries replace matching defaults, while other default entries remain. Other nested arrays are replaced as a whole. Keep a fallback when a setting inside one of those replaced arrays is intentionally optional. +- **Always use American English spelling** — E.g., "behavior" vs "behaviour", "utilize" vs "utilise". + +### Configuration + +These rules apply to all code, including ported code. + +- Always use typed getters for values with one non-null type. Only use `get()` when null, union, or mixed values are meaningful. Add a test for any supported null behavior. +- Cast environment-backed booleans and numbers in config files; if `null` is supported, cast only non-null values. Consumers must not repeat those casts. When a factory accepts raw configuration records, normalize types and documented optional defaults once at that boundary; never supply missing required members. +- Required settings live in shipped config and must not have a code-level fallback, so missing or misspelled keys fail loudly. +- Intentionally optional settings keep one owning fallback and remain discoverable in config or feature documentation. Document what omission or null means. +- Framework and package defaults are shallow-merged with application config. `mergeableOptions()` is only for named groups such as connections or stores: application entries replace matching defaults, while other default entries remain. Other nested arrays are replaced as a whole. +- Replaceable named and nested records must apply documented optional defaults at their owning boundary; missing required members must still fail. +- A non-obvious fallback value shared by multiple source paths must have an owning constant. If user-facing config exposes the same default, keep its concrete value readable and add a focused test asserting that it matches the constant. Do not create constants for obvious source fallbacks such as `true`, `false`, `null`, `[]`, or `'default'`. - **Env var naming** — Ported config keeps upstream names. New Hypervel-specific settings should use the established prefix for the package or subsystem that owns the value (`SERVER_`, `CACHE_`, `REDIS_`, etc.). Determine ownership semantically, not from the config filename: aggregate files such as `app.php` contain multiple domains, and `APP_` is for genuinely application-wide settings. If a value mirrors another config key, reuse that key's environment variable instead of defining a duplicate. - **Use `resolve...Using` for Hypervel-owned config resolvers** — prefer this naming for callbacks that resolve config-derived values, unless an established Laravel domain convention already exists, such as `redirectUsing()`. -- **Always use American English spelling** — E.g., "behavior" vs "behaviour", "utilize" vs "utilise". ## Container @@ -729,7 +740,7 @@ Full PHPStan runs through `composer fix` at checkpoints. During implementation, When porting Laravel packages, whether first-party or third-party, keep them as close to 1:1 with upstream as possible so future changes are easy to merge. The exceptions are: - Modernizing PHP types, including native parameter, return, property, and class-constant types, plus other appropriate PHP 8.4+ features, strict types, and strict comparisons - Converting mutable Laravel date construction to Hypervel's immutable date conventions, typing configurable factory output as `CarbonInterface`, and capturing date-modifier return values -- Converting container array access (`$app['events']`) and dynamic service-property access (`$app->events`) in ported code to named container methods, and untyped `$config->get()` calls to typed getters where the key isn't nullable (see Container and the typed-getter rule under Development Conventions) +- Converting container array access (`$app['events']`) and dynamic service-property access (`$app->events`) in ported code to named container methods, and applying the Configuration rules to ported config and its consumers - Adding Laravel-style title docblocks to methods (not classes — see Development Conventions) - For ported Laravel packages: making them coroutine-safe, adding Swoole performance enhancements (e.g., static property caching), making them pass PHPStan - Not porting upstream framework-specific integrations that only make sense in the source framework (for example packages, drivers) unless Hypervel intentionally has an equivalent surface diff --git a/docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md b/docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md index 679508782..1489e9050 100644 --- a/docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md +++ b/docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md @@ -474,7 +474,7 @@ Cover: - no command is invoked twice; - Redis and Valkey integration confirms synchronized server-error equality where a stable command can produce it. -Delete replay assertions. In `src/boost/docs/redis.md`, delete only the framework replay sentence. Keep native phpredis `retry_interval`, `max_retries`, and backoff documentation. +Delete replay assertions. In `src/boost/docs/redis.md`, delete only the framework replay sentence. Keep native PhpRedis `max_retries` and backoff documentation; Hypervel does not expose `retry_interval` because its complete backoff policy replaces that initial seed before issuing a command. ## 3. Replace subscriber EOF framing with exact RESP2 decoding diff --git a/docs/plans/2026-08-14-2317-config-access-and-legacy-fallback-audit.md b/docs/plans/2026-08-14-2317-config-access-and-legacy-fallback-audit.md new file mode 100644 index 000000000..96ebcf9ef --- /dev/null +++ b/docs/plans/2026-08-14-2317-config-access-and-legacy-fallback-audit.md @@ -0,0 +1,306 @@ +# Config Access and Legacy Fallback Audit + +## Goal + +Audit the `contrib/hypervel/components` repository so configuration reads state their real type, required keys fail loudly when missing or misspelled, intentional null and mixed behavior remains supported, and Laravel compatibility fallbacks that Hypervel does not need are removed. Required first-party settings are discoverable in their owning config file. Deliberately optional public settings keep one source fallback and remain discoverable at their natural user-facing surface: an active or commented config entry, relevant feature documentation, or both when each adds distinct value. Fixed nested arrays and driver-specific records are complete for every required member when replaced by an application. Nullable required members are explicit; optional members may use documented omission or null behavior. Other call-site defaults remain only for intentional optional behavior, generic or runtime-created records, genuinely open-ended dynamic namespaces, or config owned by an optional package that may not be installed. + +## Ground Rules + +- Audit production PHP, route/config files, migrations, and Blade views. Distinguish configuration repositories from unrelated `get()` methods. +- Treat shipped config as the canonical schema for required, statically named first-party settings so keys and types are discoverable to humans, tooling, and LLMs. Declare required settings even when their default is null: key presence documents intent and distinguishes inheritance or disabled behavior from a missing or misspelled key. A deliberately optional stable public setting may instead use one source fallback, but it must remain discoverable at the most useful surface. Prefer a commented config example when the option is concise and useful while editing that file; prefer feature documentation when the behavior needs workflow or conceptual context. Do not duplicate it across both surfaces unless each adds distinct value. Prefer each config section's heading block for omission, null, inheritance, alternate-driver, and advanced-setting semantics; add a focused section block where useful. When only one key needs explanation, use a durable leading comment such as `Set to null to use the default database connection.` Never use a trailing comment that describes the current literal and becomes false when the value changes. If a value is not intended to be configurable, remove the config indirection and keep it as an internal invariant. Dynamic user-defined namespaces and open-ended third-party option bags expose their supported parent or record shape in the owning section block rather than pretending to enumerate every possible child. Generic caller-supplied keys and internal values created during bootstrap are not shipped settings. Do not add deprecated or backwards-compatibility-only keys to config; remove those reads instead. +- Keep obvious local fallbacks (`true`, `false`, `null`, `[]`, and ordinary `'default'`) as literals. Use an owning constant only for a non-obvious domain value shared by genuine source fallback paths where drift would cause incorrect behavior; never introduce default-holder classes or cross-package coupling solely to deduplicate literals. User-facing config and publishable stubs keep the concrete value readable rather than referencing these default constants. When config exposes the same default, focused config coverage asserts that its literal remains aligned with the owning constant. +- Use `string()`, `integer()`, `float()`, `boolean()`, `array()`, or `collection()` when the consumed value has one required type. Use `collection()` when a required config array is immediately wrapped in a `Collection`. Helper-based code uses `config()->{type}()`; ported code keeps its helper/facade/injection structure. +- Normalize environment-backed booleans and numbers at the owning config declaration because typed getters validate loaded values rather than coercing them. Preserve meaningful null by casting only non-null values. Public factories that accept caller-created raw records normalize types and documented optional defaults once at that boundary; they never supply missing required members. Ordinary consumers do not repeat config casts. +- `Arr::get()` treats a present null key as present because `Arr::exists()` uses `array_key_exists()`. A call-site fallback for a key present in loaded config is therefore dead even when the configured value is null. +- Remove a call-site default only after confirming the exact key exists in shipped config, the owning config has loaded before the read, and omission is not supported. Package defaults merged in `register()` are unavailable to `isEnabled()`. A missing required key fails at its first typed or direct read and names the missing key without a compatibility layer. +- Match regression tests to the failure mechanism. A plain `Repository` can prove a typed-getter `InvalidArgumentException`; an assertion about the application's converted undefined-key `ErrorException` requires a booted Testbench application. A harness stub that replaces the real config boundary cannot serve as shipped-record integration coverage. Keep PHPUnit's `failOnWarning="true"` as a safety net, not as proof that every conversion ran or every shipped record is correct. +- Keep `get()` or an appropriate fallback when the read has meaningful null, union, mixed, dynamic, or deliberately optional behavior, or when it targets config owned by an optional package that may not be installed. Add or retain a functional test for meaningful null behavior. Do not classify a value as nullable or optional merely to preserve an omitted key from an old, partial, or upstream config file. +- Named config groups in `LoadConfiguration::mergeableOptions()` merge entry by entry, with an application entry replacing the framework entry of the same name. Other nested arrays replace the framework or package array wholesale. At every depth, an application that replaces a fixed array or named driver record must provide every member classified as required for the selected variant. Required nullable or inheriting members are declared explicitly; absence does not make them optional. A deliberately optional member may be omitted only when its owning consumer keeps the documented source fallback. A member that belongs only to another driver variant is not added as an irrelevant null key, but that variant and its required shape are documented at the natural user-facing surface. Generic APIs that accept runtime-created records and open third-party option bags may retain source defaults, but those fallbacks do not excuse incomplete required schemas. +- Evaluate application and framework config files in isolated closures so a file's local variables cannot overwrite loader state or leak into another file. +- Retain established member defaults when named resolution shares a public caller-record boundary that deliberately accepts partial records. The complete list is: `AuthManager::createSessionDriver()` / `createTokenDriver()`, `BroadcastManager::pusher()`, `CacheManager::build()` / `repository()`, `FilesystemManager::build()` and its public creators, `LogManager::build()`, `MailManager::build()` / `createSymfonyTransport()`, `ConnectionFactory::make()` / `parseConfig()`, `ServerFactory::configure()` / `ServerConfig::__construct()`, `Port::build()`, `Watcher\Option::fromConfig()`, and `PoolOptions::fromArray()`. Replaceable named and nested records also retain documented optional-member defaults at their owning connector, factory, or normalization boundary; required identity, schema, topology, routing, and security members remain strict. Do not add provenance markers, duplicate factories, recursive merging, or validation machinery solely to distinguish named from caller-created records. Shipped examples are canonical and remain complete enough to show useful optional settings. Low-level Redis connection constructors receive complete effective records from `RedisConfig`. The audited public paths with no member fallback to retain are `BroadcastManager::ably()`, `ConnectionFactory::createConnector()`, `TlsOptions::fromArray()`, `SupervisorOptions::fromArray()`, `PoolFingerprint::fromConfig()`, the Bcrypt/Argon constructors' required named members, and Cache `Repository`'s internally injected nullable store name. Runtime method-option records such as Vite prefetch options, HTTP client connection presets, and Markdown converter options are not application configuration and keep their own API defaults. +- Required non-null consumers use typed getters without defaults. A required nullable member uses direct access when omission must fail. Intentionally optional members keep their documented owning fallback, and no presence guard is added when omission and null have the same valid behavior or both reach purpose-built downstream validation. Whenever a nullable declaration changes, inspect every presence-sensitive consumer across all of `src/`. New required members are deliberate config-schema changes for applications that replace the enclosing array and must be documented for upgrades. Lists and fixed records must not be recursively merged because doing so prevents reliable replacement and clearing. +- Remove old Laravel key names, env names, and config shapes only when upstream history confirms that the branch exists for older applications rather than current functionality. Record public porting consequences concisely. +- Do not add wrappers, compatibility aliases, generic normalization layers, custom exception types, or new configuration machinery. Normalize only at an existing boundary that owns raw or replaceable records. + +## Final Configuration Contract + +### Current-only keys and shapes + +1. Scheduling mutex cache configuration is `cache.schedule_store`, backed by `SCHEDULE_CACHE_STORE`. A null value selects the cache manager's default store. Adding this top-level key makes the old kernel fallback unreachable even when its value is null; remove the dead direct env chain so `SCHEDULE_CACHE_DRIVER` is unsupported by design. +2. `database.migrations` is the current array shape. `table` is required schema identity. `update_date_on_publish` is optional and defaults to false when omitted. The old scalar table-name shape is rejected instead of normalized at each caller. +3. `logging.deprecations` is the current array shape. `channel` and `trace` are optional members: Foundation defaults to the null channel with traces disabled, while Testbench defaults traces on so deprecations identify their call sites. An explicitly null channel maps to the null logger, and a named channel must resolve to a configured channel array. Keep Testbench's intentional `rescue(..., report: false)` around parent deprecation reporting. The old scalar channel shape is rejected. +4. Sentry logging uses `SENTRY_LOG_LEVEL`, falling back to the general `LOG_LEVEL`. Upstream deliberately renamed `SENTRY_LOGS_LEVEL` and retained it only as a backwards-compatibility alias; Hypervel removes that alias. +5. Socialite's `services.x` / `services.x-oauth-2` lookup remains. Both keys were introduced together for the single current X provider, and the undeprecated upstream alternate key is not a renamed-key shim or a separate driver. +6. Dynamic connection inheritance, generic package merge defaults, and current per-driver configuration such as `concurrency.driver.{name}` remain unchanged. +7. Hypervel-specific names use their final current forms: `queue.concurrency` / `QUEUE_CONCURRENCY`, `FORCE_HTTPS`, singular `AUTH_USER_CACHE_*` variables with the `auth_user` default prefix, `REVERB_APP_RATE_LIMIT_ENABLED`, `REVERB_APP_RATE_LIMIT_TERMINATE_ON_LIMIT`, and `SANCTUM_LAST_USED_AT_UPDATE_INTERVAL`. Do not retain aliases for the replaced names. +8. Environment-backed booleans and numbers are normalized at the owning config declaration or runtime record-construction boundary, including nullable values only when non-null. Public raw-record boundaries still normalize caller-created records once, but ordinary consumers do not repeat config casts. Focused config tests use representative string inputs for each affected surface. +9. Testbench config contains only intentional application overrides. It inherits framework defaults for `app.debug`, the session cookie name, rate-limiter stores and prefix, and auth documentation; its replacement Eloquent provider record remains complete for required members. + +Remove migration-shape normalization from `DatabaseServiceProvider`, `DumpCommand`, and `DatabaseTruncation`; remove the deprecation scalar-shape branches, the strict presence helper, and Testbench's direct trace env fallback from the Foundation and Testbench `HandleExceptions` bootstrappers; remove direct env access from the Foundation console kernel; and remove the renamed Sentry env alias from its package config. + +### New shipped declarations + +Add these settings at their owning config surface. Required settings have no source fallback; optional settings retain the behavior described below: + +- `cache.schedule_store => env('SCHEDULE_CACHE_STORE')` +- `auth.passwords.users.driver => 'database'`; the password-broker section documents the required database/cache record members and the optional database `connection` and cache `store` selectors +- `fortify.limiters.verification => '6,1'` in both package config and publishable stub; omission uses the Fortify-owned `6,1` fallback +- `horizon.env => env('HORIZON_ENV')`; document that this advanced override selects a Horizon provisioning environment independently of `app.env`, while null inherits `app.env` and the command's `--environment` option takes precedence +- `sanctum.routes => true` +- `sanctum.prefix => 'sanctum'` +- `scout.algolia.connect_timeout`, `read_timeout`, and `write_timeout => null`; document that these advanced integer-second options override the Algolia SDK timeouts and null leaves its defaults unchanged + +### Named and driver-specific record schemas + +Cross-check every first-party named-record consumer against the shipped example for its selected driver. Classify each stable member as required or deliberately optional at that consumer boundary. Declare every required member, including required null inheritance, and document optional, alternate-driver-only, or shared dynamic behavior at its natural user-facing surface. Do not fill one variant with keys that have no meaning for it. Apply this to auth brokers and guards, cache stores, queue connections and failed-job drivers, database connections, filesystem disks, log channels, mailers, Horizon wait thresholds, rate-limiter stores, server definitions, and other first-party driver registries found by the residual array-fallback audit. Cache stores, filesystem disks, log channels, and mailers ship canonical required cores while omitting or commenting documented optional members; their factory-member reads follow the public-builder exception rather than the direct-access conversion rule. + +Known corrections from the initial cross-check; the residual sweep remains authoritative for every other named-record consumer: + +- Auth guard and provider records follow their selected driver schemas. Per-guard `password_timeout` is optional: omission or null inherits `auth.password_timeout`, while a configured integer overrides it and malformed values fail at the full guard key. The shipped guards omit it, and the web guard keeps a commented one-hour example. Guard `passwords` selection is also optional: the session guard selects `users`, while Sanctum and JWT omit the member because they do not select a broker by default. Missing and null remain equivalent at `resolveBrokerNameForGuard()` because `getDefaultDriver()` supplies the purpose-built error when password-reset behavior needs a broker. The session guard's `remember` member is optional: null and omission both keep `SessionGuard`'s constructor-owned 400-day lifetime, a configured integer overrides it in minutes, and the web guard keeps a commented 30-day example. Named resolution shares the omission-tolerant public `createSessionDriver()` path. JWT guards omit `ttl` to inherit global `jwt.ttl`, use an integer to override it, or use null to create non-expiring tokens. Unsupported configured or inherited values fail with the guard name. Built-in session, token, Sanctum, and JWT guards require a provider. Token `input_key`, `storage_key`, and `hash` remain optional because `TokenGuard` owns matching constructor defaults. The Eloquent provider's entire `cache` record may be omitted or null to disable caching; a supplied partial record defaults `enabled` to false, `store` and `tags` to null, `ttl` to 300 seconds, and `prefix` to `auth_user`. Own the shared TTL through `EloquentUserProvider::DEFAULT_CACHE_TTL`; keep the existing prefix constant. Apply these defaults in both `CreatesUserProviders::createEloquentProvider()` and `AuthServiceProvider::cachedEloquentProviders()`. Database and custom providers do not gain irrelevant cache members. The database provider requires `table`; its optional `connection` selector defaults to null. Retain the `driver ?? null` read that selects custom user-provider creators and feeds the existing unknown/missing-driver `InvalidArgumentException`. Testbench replaces only the required driver/model members of its Eloquent provider record and inherits optional cache behavior. +- Password brokers are explicit discriminated records. `driver` and `provider` are required; the database variant also requires `table`. `expire` and `throttle` are optional and default to 60 and zero. Database `connection` and cache `store` are optional selectors; omission or null uses the corresponding default connection or store. Own the expiry shared by broker construction and reset notifications through `PasswordBrokerManager::DEFAULT_EXPIRE_MINUTES`. Email-verification expiry independently defaults to 60 minutes at its single owning read. The section block and password-reset documentation explain both variants and omission behavior. +- Fortify keeps its top-level route `middleware`, `views`, `auth_middleware`, and `home` settings required and visible. Email verification is always rate limited; its nested `limiters.verification` member is optional with the Fortify-owned `6,1` default, while the four `passkeys` members are optional overrides that derive from `app.url` / `app.key` or use the Passkeys-owned 60-second timeout. Keep all five nested settings active in the package config and published stub. Explicit null or empty passkey values retain their documented validation at first WebAuthn use. State the verification rule concisely in the config, published stub, and rate-limiting documentation. +- Standalone Passkeys keeps all settings active and documented in its config. `middleware` and `management_middleware` remain required because they define the route and password-confirmation security boundary. `guard` remains optional and nullable; omission or null uses the current request guard. The WebAuthn `timeout`, route `throttle`, and login `redirect` are optional with source defaults of 60,000 milliseconds, `throttle:6,1`, and `/`; explicit null still disables throttling. Own the shared non-obvious timeout through `Passkeys::DEFAULT_TIMEOUT`, keep its config literal readable, and assert alignment in focused config coverage. Keep the single-use throttle and redirect defaults at their source reads. Relying-party ID and allowed origins may instead come from request-aware resolvers, while missing static identity values retain their purpose-built errors at first use. The user-handle secret defaults to `app.key` in config and likewise fails at first use when unavailable. Document the five standalone route settings in one compact table rather than adding more headings. +- Cache store examples omit shared optional members. Database, storage, and Redis may set a per-store `prefix`; omission or null inherits `cache.prefix`. File-store `permission` is also omitted; omission or null leaves permissions to the operating system, while a configured value is one shared mode for cache files and generated directories. Do not suggest a concrete mode because no single conventional file mode is also an ideal directory mode. Ordinary repositories default `events` to true, while failover deliberately defaults its outer repository to false because its backing repositories already dispatch ordinary events. Keep these source fallbacks in the built-in factory methods: public `CacheManager::build(array $config)` and `repository(Store $store, array $config = [])` accept caller-created partial records, and they are also the single defaults for named stores. The config section documents the optional prefix, permission, and event settings once instead of copying them into every record; the Cache Events documentation explains the failover exception and that `CacheFailedOver` remains dispatched. The Redis benchmark's recovery guidance treats both an omitted and null selected-store prefix as inheritance from `cache.prefix`. Do not add validation, marker fields, or a second factory path just to distinguish named resolution from the public boundary. +- Sanctum keeps its dedicated Routes config section and both active entries so the externally visible CSRF endpoint remains clear to application and infrastructure maintainers. The `routes` member is required so a missing or misspelled setting cannot silently enable an endpoint. When routes are enabled, `prefix` is also required and read without a fallback. Disabling routes returns before reading the prefix. +- Sanctum's replaceable `middleware` record supports omission of each member. Normalize it with `EncryptCookies`, `PreventRequestForgery`, and null session-authentication defaults; explicit null still removes a middleware. Its cache record defaults to disabled, null/default store, a 300-second TTL, the `sanctum` prefix, and a 300-second last-used update interval. Own the two shared numeric defaults through `Sanctum` constants and keep readable config literals with alignment coverage. +- Inertia's replaceable nested records retain upstream omission behavior: SSR enabled true, runtime `node`, runtime check false, throw-on-error false, bundle check true, URL `http://127.0.0.1:13714`; pages existence false; history encryption false; testing pages existence true. Keep page paths and extensions required. Existing connect timeout, request timeout, backoff, bundle, and hot-URL behavior remains unchanged. +- JWT's replaceable provider record defaults the JWT implementation to `Lcobucci::class` and storage to `TaggedCache::class`; guard provider identity remains required. +- Scout keeps its top-level operational flags and prefix strict because package config merging supplies them. Queue enablement defaults to false when its replaceable nested record omits the member. Searchable and unsearchable chunk sizes default to 500 through `Scout::DEFAULT_CHUNK_SIZE`. A partial Meilisearch record defaults its host to `http://localhost:7700`, retries to 3, and initial retry delay to 100 milliseconds; active credentials remain required. The three nullable Algolia timeout entries remain optional overrides, and Typesense's commented `max_total_results` option retains its typed 1,000-result source fallback. A missing or PHP-null resolved `scout.driver` selects `NullEngine`, while omitting `SCOUT_DRIVER` still loads the shipped `collection` default. Generic engine settings and model/index registries remain dynamic. +- Reverb and Pusher broadcast records omit the optional `log` member and show a commented `true` example. Omission disables SDK logging through the `false` fallback owned by the public `BroadcastManager::pusher()` factory; the feature documentation distinguishes its full outbound-payload logging from both the broadcast log driver and Reverb server logging. Pusher/Reverb resolution also defaults omitted `jsonp` to false, while the public Pusher factory retains its partial-record defaults for `client_options` and `options`. The Ably record is an open third-party option bag: Hypervel passes the whole record, including `driver`, to the SDK, so its supported shape is documented without inventing framework defaults. +- A Redis broadcast record may omit `connection`; the broadcast manager then selects the default Redis connection. Pusher `jsonp` omission remains false. +- Keep normal top-level configuration strict because framework/package merging supplies it. Restore only resilient boundary behavior: exception and health rendering fall back to `Hypervel` if application-name configuration is unavailable, and `GeneratorCommand::viewPath()` falls back to `$this->hypervel->basePath('resources/views')` when the configured path list is empty. The GeneratorCommand method is a separate live path from `Application::viewPath()` and must not call the Foundation-only `resource_path()` helper from the Console package. +- Reverb's config application provider requires application identity and protocol/security members: `app_id`, `key`, `secret`, `ping_interval`, `allowed_origins`, and `max_message_size`. Optional application members default at construction: `activity_timeout` to 30, `max_connections` and `rate_limiting` to null, `accept_client_events_from` to the safer `members`, and `options` / `webhooks` to empty arrays. Own the two shared non-obvious defaults through `Application::DEFAULT_ACTIVITY_TIMEOUT` and `Application::DEFAULT_ACCEPT_CLIENT_EVENTS_FROM`. Normalize a supplied partial rate-limiting record once in `Application`: enabled false, max attempts 60, decay 60 seconds, terminate-on-limit false. Normalize every webhook member consumed by `hasWebhooks()`, lifecycle paths, `HttpWebhookDispatcher`, and `FlushWebhookBatchJob`: URL null, events/headers empty, both filters null, subscription counting false, disconnect smoothing 3,000 milliseconds, timeout 5 seconds, retries 3, retry delay 1 second, batching disabled, maximum 50 events, 250-millisecond delay, and 262,144-byte payload. Null or blank URL disables webhooks. The shipped application remains a complete canonical example. Keep `Application::toArray()` as the Pusher client configuration shape. Document omission behavior in Reverb documentation, not the Laravel porting guide: both projects ship `members`, and only hand-written partial records encounter Laravel's inconsistent permissive source fallback. +- Filesystem disk, log channel, and mailer examples share public caller-record boundaries. Public `FilesystemManager::build()`, `LogManager::build()`, and `MailManager::build()` reach the same driver or transport construction paths as named resolution; filesystem creator methods and `MailManager::createSymfonyTransport()` also accept caller-created records directly. Keep the source fallbacks that define those runtime APIs. Shipped filesystem disks explicitly declare their security-relevant default `visibility` and their `throw` / `report` failure policy. The S3 disk also declares `root => env('AWS_ROOT', '')` because its bucket namespace is a core disk choice, and shows the optional AWS session token as a commented example. Keep the established `AWS_*` names so explicit credentials align with the AWS SDK and other AWS services, while the SDK's default credential chain remains available when they are absent. Omit active defaults for local permissions, directory visibility, locking, link handling, serving, read-only mode, S3 SDK version/adapter options/client options, and GCS client options; the public builders own those optional defaults and the feature documentation explains the useful opt-ins. Shipped single and daily log channels keep explicit null file permissions but omit optional bubbling and locking. The Slack channel keeps `context` and `exclude_fields` visible because they control structured data sent to an external service, while optional `channel`, attachment layout, and bubbling use the public builder defaults. Its concise documentation table covers every effective option and explains that Slack fixes the webhook destination while compatible services such as Mattermost may honor destination and identity overrides. The error-log channel omits its ordinary handler type, and the null channel omits the default level and empty handler/processor arrays. Shipped mailers omit empty SES and HTTP-client options, SMTP source IP, and explicit empty pool records; named poolable mailers already pool by default. Postmark retains commented message-stream and client-timeout examples. Mail documentation covers those options, SMTP source binding, shared HTTP-client options, and pooling without duplicating defaults in every record. `DatabaseManager::build()` is the deliberate unsupported dynamic-connection stub and reaches no factory, so it does not qualify for this exception. +- Queue connection examples keep ordinary operational choices visible while optional and alternate-driver settings use one documented source default. Sync and database keep `after_commit => false`; background, deferred, Beanstalkd, SQS, Redis, and failover use true. Each connector owns the matching omission default. Database queue `connection` defaults to null and `retry_after` to `DatabaseQueue::DEFAULT_RETRY_AFTER`; table and queue identity remain required. `DatabaseQueue` requires an integer retry duration because reservation expiry always subtracts it, while Redis retains its supported null retry behavior. Beanstalkd `retry_after` uses `Pheanstalk::DEFAULT_TTR`, `block_for` uses the obvious zero sentinel, and timeout omission or null delegates to Pheanstalk; host, port, and queue remain required. Redis queue connection defaults to the connector's configured connection, `retry_after` to `RedisQueue::DEFAULT_RETRY_AFTER`, and `block_for` to null; queue identity remains required. Horizon's Redis connector uses the same defaults. Keep retry-after constants per driver rather than coupling them on the base queue. SQS optional configuration remains normalized through `SqsConnector::getDefaultConfiguration()`. Redis migration batches continue to use `RedisQueue::DEFAULT_MIGRATION_BATCH_SIZE`. Extend the existing connector/config alignment test with retry-after assertions instead of adding parallel per-class tests. File failed-job `path` and `limit` remain documented optional alternate-driver settings; database failed-job records keep a required nullable database selector. +- Ship Laravel's `failover` example with `database` then `deferred`, add the registered driver to the config heading, and declare `after_commit => true`. Whole-chain deferral is the only setting that keeps failures from realistic after-commit children inside the failover catch boundary. It deliberately gives up the database primary's same-transaction job insertion; failover cannot guarantee transaction atomicity across children that do not share the business transaction. Job-level `afterCommit()` and `beforeCommit()` remain authoritative. With wrapper `after_commit => false` and no job override, children retain their own timing; a child-deferred failure occurs outside the wrapper's immediate attempt. Do not add context flags, mutate cached children, construct duplicate child connections, or add a queue-wide override API for that explicit mixed-policy configuration. +- Repair failover's transaction boundary at the logical queue. Before the existing state-mutating `try` / `finally`, apply `shouldDispatchAfterCommit()` and snapshot the distinct connection names in `callbackApplicableTransactions()`. Build the complete pending set before registering callbacks, then register cancellation on every snapshotted connection before registering any commit callback because `addCallback()` may execute inline while the rollback form does not. Shared local state runs the complete child attempt chain exactly once after every snapshotted connection commits, while the first rollback cancels the dispatch and releases unique/debounce locks exactly once. Re-entry snapshots a new live stage if a transaction opened after dispatch, keeping every child deferral inside the failover catch boundary. Nested same-connection callbacks remain staged until root completion, while rollback of a snapshotted savepoint cancels the dispatch. This completes the existing whole-chain commit policy on its rollback side without changing ordinary queues' Laravel-compatible latest-transaction semantics. Keep `pushRaw()` immediate and retain coroutine-scoped failure-suppression state only around actual child attempts. +- Preserve Laravel's enqueue-only failover contract: reads, inspection, and `pop()` delegate to the first child, workers consume named child connections, and synchronous `QueueFailedOver` listener exceptions propagate normally. Hypervel's worker passes a Redis-specific multi-queue index outside the core contract. Add the standalone supplemental `Hypervel\Contracts\Queue\IndexAwareQueue` capability, implemented by `RedisQueue`, `FailoverQueue`, and `QueuePoolProxy`; Horizon inherits it. Worker and both wrappers probe the capability, forward the index only to aware children, and use ordinary one-argument `pop()` otherwise. Remove Worker's inaccurate Redis type override and keep the core Queue contract unchanged. This repairs Laravel's still-unfixed failover omission from the Redis multi-queue optimization and Hypervel's publicly supported opt-in pooled Redis path. +- Restore clearing through pooled built-in database, Redis, and SQS queues with `ClearableQueuePoolProxy`, selected by a protected driver-to-proxy-class map. The subclass forwards through the existing synchronous lease lifecycle, so operation errors remain primary and cleanup behavior is unchanged. Default Beanstalkd and custom poolable drivers retain `QueuePoolProxy`; a custom clearable poolable driver may extend `QueueManager` and its protected map until a real consumer justifies public registration machinery. Explain that boundary at the map and in the queue documentation. Do not implement `ClearableQueue` on `FailoverQueue`: jobs may exist on several fallback backends, so clearing only the first would be a misleading partial destructive operation, and Laravel does not promise failover clearing. +- Make base `Queue::bulk()` route jobs with a `Delay` attribute or delay property through `later()` and ordinary jobs through `push()`, matching Laravel's current FailoverQueue behavior at the shared boundary needed by Hypervel's background and deferred queues. Remove Beanstalkd's stale duplicate. Keep the specialized Database, Redis, and SQS batch implementations. +- `RedisConfig::connectionConfig()` is the single normalization boundary for every optional Redis member. It merges the required shared top-level options with optional per-connection options, and supplies null username/password; database zero for standalone and Sentinel only; null scheme/name/timeout/prefix; zero read timeout; empty context/per-connection options/pool; disabled events; and retry defaults of 3, `decorrelated_jitter`, 100, and 1000. Standalone host/port, Cluster seeds, and Sentinel nodes/master name remain required topology. Name and logical database do not apply to Cluster. Null or omitted timeout inherits `pool.connect_timeout`; null or omitted prefix inherits the shared options prefix; null or omitted name disables `CLIENT SETNAME`. Standalone, Sentinel, and Cluster remain distinct documented variants. Redis integration-test configuration must build the same exact Cluster variant. Remove `retry_interval` from Hypervel's configuration surface because the explicit backoff policy supersedes it before any command; pass a literal positional zero to `Redis::connect()` because `read_timeout` follows it in the native signature. Low-level consumers receive the complete effective record and must not keep duplicate defaults or recursively merge a second schema. +- Database `ConnectionFactory::make()` / `parseConfig()` retains its public caller-record default for an absent prefix and its internal connection name. `createConnector()` keeps only its purpose-built driver validation. `MigrateCommand` reads its selected named connection with `array()` before passing the record to the URL parser; scalar connection records are not supported by `DatabaseManager`. +- Server `ServerConfig` / `Port`, Watcher `Option`, and `PoolOptions` retain their class-owned public partial-record defaults. Their shipped named records remain complete. `TlsOptions`, Horizon `SupervisorOptions`, and `PoolFingerprint` have no config member default to convert. +- Horizon keeps deployment and runtime selections required: `path`, `proxy_path`, `use`, `prefix`, `middleware`, `fast_termination`, `memory_limit`, and `environments`. An empty `proxy_path` explicitly means no external reverse-proxy prefix. `name`, `domain`, `env`, and `watch` retain their documented inheritance or nullable behavior. `defaults`, `silenced`, and `silenced_tags` are optional arrays with empty defaults. Job-retention members are optional integer overrides: recent, pending, and completed default to 60 minutes; failed and monitored default to 10,080 minutes; and omitted `recent_failed` inherits the effective failed retention. Metric job and queue snapshot retention default independently to 24, while the snapshot lock defaults to 300 seconds. Keep readable literals in user-facing config. Use owning constants only for non-obvious values shared by multiple production paths; keep the single-use pending, completed, metric-retention, and lock defaults at their source read. Apply the corrected values consistently across repositories, listeners, commands, and dashboard responses rather than retaining Laravel's anomalous 2,880-minute failed-tag fallback or its missing recent-dashboard fallback. The wait-threshold section states that an unlisted dynamic connection/queue pair uses 60 seconds and that zero disables its alert. Database migration-connection routing and per-store cache-prefix inheritance receive the same durable section-level explanation at their owning dynamic registries. +- Telescope requires its deployment-facing dashboard path, storage driver, database connection, route middleware, and watcher registry. The path is a string rather than an undocumented null state; the package config remains the visible route contract. The database insertion chunk size is optional and falls back to `DatabaseEntriesRepository::DEFAULT_CHUNK_SIZE`, while `defer` defaults to true and omitted `only_paths`, `ignore_paths`, and `ignore_commands` arrays default to empty. `ClientRequestWatcher` owns its duplicated 64-kilobyte omitted request-size limit with one protected constant; unrelated single-site watcher limits remain literals. Keep all of these settings visible in the shipped config. Its existing section blocks explain the storage connection and chunk behavior, the authorization responsibility of route middleware, and the distinction between path allowlists and path/command exclusion lists. The nullable domain and queue settings retain their documented behavior. +- Permission keeps its fundamental schema explicit: role and permission models, every table name, `model_morph_key`, and the teams schema switch remain required. Role and permission pivot keys may be omitted or null to select their existing conventional constants; omitted `team_foreign_key` uses `PermissionRegistrar::DEFAULT_TEAM_FOREIGN_KEY`. The four nested cache-key names are optional and use their existing package values; add an owning constant for the role-catalog key so both registrar and migration paths share it. The nullable team and default-assignment models, feature flags, resolvers, cache expiration/store/column exclusions, and custom wildcard parser retain their documented optional behavior. User-facing config keeps readable literals and focused tests enforce alignment only for non-obvious shared constants. +- Rate Limiter keeps default store, prefix, driver discriminators, database table, Redis connection, and Swoole capacity/collision/pruning members required and active. Database `connection` remains optional and nullable. Swoole `memory_limit_buffer` is also optional with the existing 0.05 default, matching the Cache Swoole store. Keep it as a direct single-site fallback rather than adding a constant or helper. +- Saloon requires its named HTTP connection, open options array, and generated-integration path. Individual connection options are independent optional transport settings. The cache and rate-limiter store selectors remain optional and nullable, with omission or null selecting the corresponding framework default. The fixture path and missing-fixture policy are optional with package defaults of `tests/Fixtures/Saloon` and false; false records a real response when a fixture is absent, while true is the replay-only setting for CI. The generated-integration namespace remains optional and nullable, deriving `Http\Integrations` beneath the application root namespace when absent. Keep all settings active and add concise section blocks rather than duplicating the feature guide. +- Tinker's `commands`, `alias`, `dont_alias`, and `casters` settings are optional extension lists with typed empty-array fallbacks. Keep the first three active in the shipped config and `casters` as a commented example under its existing section heading. `trust_project` keeps PsySH's string, boolean, and null behavior. +- Watcher's public `Option::fromConfig()` boundary retains source defaults for an omitted driver, watch list, and scan interval. The shipped config keeps all three active: `ScanFileDriver` is the dependency-free, cross-platform default that detects additions, modifications, and deletions; the 2,000-millisecond polling interval remains owned by `Option`; and omitting `watch` leaves only CLI-supplied paths. The executable and command remain required when automatic server restarting is enabled, while `--no-restart` does not construct that strategy. Existing config headings and feature documentation cover the complete surface without further prose. +- Sentry declares every stable first-party feature flag. Add `breadcrumbs.storage` and `tracing.storage`, normalize env-backed booleans and the SQL-origin threshold in config, preserve Spotlight's `false|string` union, and retain application-defined feature defaults plus open SDK/runtime records. Because `breadcrumbs` and `tracing` are replaceable nested records, `SentryConfig::all()` normalizes omitted members while preserving unknown SDK options. Breadcrumb defaults are: logs/cache/storage/SQL queries/SQL transactions/queue/commands/HTTP client/notifications true, SQL bindings false. Tracing defaults are: queue-job transactions/jobs, SQL queries/origin, views, HTTP client, cache, storage, Redis origin, notifications, and continue-after-response true; SQL bindings, Redis commands, and missing routes false; SQL-origin threshold 100 milliseconds. Read each group with the repository array getter so wrong group types still fail. `SentryServiceProvider::getUserConfig()` must delegate to `SentryConfig::all()`; feature consumers use the same boundary. Use compact private default maps rather than constants for each boolean. When an active endpoint and enabled cache feature require cache telemetry, `CacheFeature` deliberately enables repository events for every configured store even if a store disabled ordinary events; document that coupling without adding negotiation machinery. +- Preserve Sentry's upstream-supported custom container/config alias consistently. `SentryConfig` requires the selected root, is bound before config merging, and is the one package-config/capability boundary. Provider-owned pool, feature, log-level, and publish keys derive from `static::$abstract`; fixed log/filesystem driver names and telemetry identifiers do not. Pass tracing middleware flags at construction. Reject a second provider before it mutates shared SDK state, and document that a custom provider replaces package discovery rather than coexisting with it. +- Redis stores a null master-supervisor environment as an empty hash string, which defeats the controller's documented `horizon.env` / `app.env` fallback after a repository round trip. Normalize exact `''` back to null in `RedisMasterSupervisorRepository::get()`, alongside its existing stored-JSON normalization, with a short comment explaining the Redis representation. Do not handle absent-field `false`: the built-in writer always writes the member, and foreign partial hashes are unsupported. This corrects `/horizon/api/masters` from `"environment":""` to `"environment":null` for a null-environment master; it is an upstream correctness fix, not a documented Laravel difference. +- Audit existing nullable config comments as part of the same pass. Replace only current-value-dependent annotations with section prose or durable leading comments. Preserve correct upstream-derived GCS option comments: they describe the option contracts rather than the current literals. Do not add comments that merely restate a key or type. + +### Passkey config loading with nullable application settings + +`app.url` and `app.key` can be null for supported application behavior. PHP eagerly evaluates function arguments, so the current `env(..., parse_url(config('app.url'), ...))` expressions can fail even when explicit passkey env values are set, and the Fortify bridge's nested typed fallback reads `app.key` even when a dedicated passkey secret is configured. + +In the Passkeys config, Fortify config, and Fortify stub: + +```php +$appUrl = config('app.url'); +$defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); +$defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; +``` + +Use those derived defaults in the returned array. Keep `config('app.key')` as the default because a null app key is valid until a secret-requiring passkey operation runs. The Fortify bridge safely derives the same defaults inside its replayed config mutation and uses them only when a member is omitted; explicit null or empty values still cross into Passkeys and reach its purpose-built validation. The WebAuthn timeout is also optional and uses the Passkeys-owned 60-second default. In `Passkeys`, read the three nullable values with `get()` and let the existing domain guards reject null, wrong-type, empty ID/origin collections, and empty secrets with their purpose-built `RuntimeException` messages. Extend the secret guard to reject non-strings as well as the empty string. + +Keep `app.url` nullable only where the consumer has explicit behavior for an application without a canonical URL. The owning config section explains that features supporting a missing URL use their documented behavior, while features requiring an absolute URL fail until one is configured. `AboutCommand` renders an empty URL, `DownCommand` reports a relative maintenance bypass path, generic console requests and routing reloads use `http://localhost`, Testbench retains its base URL, and trusted-host discovery adds no application host. Consumers that inherently require a configured URL continue using `string()`. + +## Access Conversion + +Apply the following classifications to every matching production caller. Before editing each package, re-read its README, shipped config, relevant source, and relevant tests. + +| Area | Convert to typed access and remove duplicate defaults | Keep untyped because behavior is not one required type | +|---|---|---| +| Core app/foundation | merged top-level app environment, timezone, locale, name, debug flag, providers, aliases, faker locale, filesystem links, view paths/compiled path, database default, required session cookie members, and consumers that require a non-null app URL/key | resilient error/health titles, empty generator view paths, package consumers of nullable app URL/asset URL/editor/key, logging default, and session domain enumerated below | +| Auth, bus, queue, database | batching table, required failed-job settings, password-broker discriminator/provider/table identity, required guard providers, and migration table | nullable batching database, optional per-guard password timeout/remember duration, token factory members, nullable guard broker selection, Eloquent cache members, password-broker expiry/throttle/connection/store, email-verification expiry, optional migration publishing behavior, queue operational defaults, dynamic auth provider/model keys, and database connection URL | +| Cache, hashing, rate limiter, signal | cache store collections and required members of shipped driver examples, hashing driver/options, rate-limiter settings, signal handlers | `cache.serializable_classes`, optional per-store prefix/permission/events, the option fallbacks shared with public `CacheManager::build()`, generic/runtime-created cache store options, and null schedule store | +| Broadcasting | default connection and required members of the shipped Pusher/Reverb/Redis records | optional Pusher-compatible SDK logging, Pusher public-factory defaults, Ably's open SDK option bag, and dynamic named-driver lookup | +| Redis | required selected-topology identity plus normalized effective members | authentication, logical database, shared/per-connection options, retry/backoff, pool, and advanced connection members normalized once by `RedisConfig`; topology discriminators, URL-derived values, event override, and open PhpRedis option/context children | +| Reverb, server, gRPC | required Reverb application identity/protocol/security members, server collections, enabled flags, route path, and server settings | optional application members plus partial rate-limiting/webhook records normalized once; nullable gRPC compression | +| Horizon | prefix/path/proxy path/use, middleware, memory and fast-termination settings, environments, and layout name reads after boot normalization | nullable name during boot normalization, domain/watch, dynamic waits, environment fallback chain, optional defaults/silenced lists/job-retention/metric-retention/snapshot-lock settings, and optional queue names | +| Inertia | required page paths/extensions and merged top-level settings | optional replaceable SSR/page/history/testing members, SSR timing/backoff, and nullable bundle/hot URL | +| JWT | algorithm, guard provider identity, keys/parser/claims/validation settings, blacklist flags, and numeric settings | optional provider/storage implementations and nullable secret/TTL/refresh/issuer | +| Filesystem and logging | required members of shipped disk/channel examples and non-factory settings | documented optional members and factory fallbacks shared with public `Storage::build()` / `Log::build()` records, plus open third-party option bags | +| Mail and notifications | required app name, top-level mail settings, and complete Markdown settings | transport fallbacks shared with public `Mail::build()` / `createSymfonyTransport()` records and optional `services.*` credentials | +| Passkeys and Fortify | feature arrays, route view flag, middleware arrays, authentication middleware, home path, and configured non-null limiter strings | optional standalone Passkeys timeout/throttle/redirect defaults, optional Fortify passkey overrides and verification limiter, and nullable relying-party ID/origins/secret, guard/domain/redirect/pipelines/other limiter entries | +| Permission | migration arrays/booleans, required role/permission models, table names, morph key, and teams schema switch | optional pivot/team foreign keys, cache keys, feature flags/resolvers, cache expiration/store/column exclusions, nullable team/default models, and dynamic guard providers | +| Sanctum | merged top-level token/routing settings and required enabled route prefix | optional middleware and cache record members, nullable expiration/store, and validated TTL/update interval defaults | +| Scout | merged top-level operational settings and required active engine credentials/schema | optional queue/chunk/Meilisearch members, nullable resolved driver, job options, and Algolia timeout overrides/key/index settings | +| Sentry | required root/pool/features/log/channel/cache records and top-level SDK options | normalized optional tracing/breadcrumb members, genuinely nullable SDK options, and dynamic option-array reads | +| Telescope | enabled flag, required path, watcher collection, middleware array, and required driver/connection values, including `Storage/EntryModel.php` | nullable domain/queue connection/queue/delay, optional defer/filter/chunk settings, and polymorphic watcher definitions | +| Tinker | — | optional command/alias/dont-alias/caster extension lists with typed empty-array fallbacks, and `trust_project`, which accepts its upstream union behavior | +| Testbench/testing | required providers, aliases, view paths, cache prefix, and database default | nullable application URL retains Testbench's existing base URL; application/test overrides deliberately allow absent app key, auth model, or connection URL; the timezone override method remains nullable for subclasses | + +Keep strict reads for merged top-level settings and required record identity. Restore one owning fallback wherever omission is supported independently of merge mechanics, including resilient app-name rendering; migration publishing; deprecation channel/trace handling; Auth cache and broker timings; Inertia, JWT, Sanctum, Scout, Permission, Queue, Redis, Reverb, and Sentry optional record members; Horizon retention; Fortify/passkey overrides; Mail Markdown; and approved advanced SQS/file failed-job settings. Do not restore redundant top-level fallbacks solely because Laravel retains them. + +Apply the required/optional classification explicitly to these previously partial blocks: + +- Read Horizon's optional `trim` and `metrics` members with typed owning defaults. Omitted `recent_failed` inherits the effective failed retention; explicit non-integer values still fail through the full typed key. Keep distinct constants for independently configurable numeric defaults even when their initial values match, and keep obvious empty-array defaults as literals. +- Pass the optional `mail.markdown` record from `MailServiceProvider` to `Markdown`, whose constructor owns the `theme`, `paths`, and `extensions` defaults. `MailChannel` and `Mailable` use the same default theme when the record or member is omitted. `database.migrations.update_date_on_publish` defaults to false. Scout Meilisearch retry settings and Fortify verification/passkey settings retain typed owning defaults because their nested records support omission. +- In `PasswordBrokerManager`, require `driver` and `provider`, select the database/cache branch explicitly, and reject unknown drivers. Database `table` remains required. Read optional `expire` and `throttle` through owning defaults, and pass optional connection/store selectors with null fallbacks. Reset and verification notification expiry reads retain their documented 60-minute defaults. +- Read `logging.deprecations` as an array in both exception bootstrappers. Foundation defaults omitted channel to null and trace to false. Testbench defaults trace to true, with one short comment that full traces identify deprecation call sites. Remove the strict presence helper and unused import. Resolve any configured named channel with `array()` so misspellings still fail at the full channel key. Preserve Testbench's rescue boundary and reject the legacy scalar shape. +- Normalize Sanctum's optional middleware record before filtering it. Defaults restore cookie encryption and CSRF validation while session authentication remains null; explicit null still removes a middleware. Normalize optional cache defaults at their owning consumers without mutating process-global config. +- Do not add missing-versus-null checks for Fortify passkey values or Scout Algolia timeouts. Those values already reach purpose-built behavior. Missing deprecation members now use their documented defaults; wrong record types and unknown named channels still fail naturally. + +### Authoritative retained-access inventory + +The following production reads deliberately remain untyped or retain a fallback. Locations are navigation anchors; the residual greps remain authoritative when edits move a read. When one location contains several config reads, the Config surface column names only the retained read; every other read there follows the conversion table. + +| Location | Config surface | Reason | +|---|---|---| +| `src/auth/src/AuthManager.php:130,170`; `CreatesUserProviders.php:58,82`; `Passwords/PasswordBrokerManager.php:119` | `app.key`, dynamic guards/providers/brokers | null / dynamic | +| `src/auth/src/AuthManager.php:224`; `CreatesUserProviders.php:31`; `AuthServiceProvider.php:233,243` | custom-request provider and user-provider driver/model validation | request guards may omit a provider; missing/unknown user-provider drivers and invalid Eloquent models reach existing domain validation; provider discovery must ignore non-Eloquent and malformed dynamic entries | +| `src/auth/src/AuthManager.php:140,159-161` | session `remember` and token `input_key` / `storage_key` / `hash` | public array-taking guard creators share named resolution and the constructed guards own the same defaults; optional null/missing remember both retain the built-in lifetime | +| `src/auth/src/CreatesUserProviders.php:91` | database user-provider `connection` | optional storage selector; null or omission uses the default database connection | +| `src/auth/src/Passwords/PasswordBrokerManager.php:136-142` | guard `passwords` selector | null or missing means no broker; the default-broker path supplies the purpose-built error when the capability is requested | +| `src/auth/src/Passwords/PasswordBrokerManager.php`; reset/verification notifications | broker expiry/throttle, database `connection`, cache `store`, and notification expiry | optional behavior; omission uses documented timing defaults or the corresponding default manager connection/store | +| `src/broadcasting/src/BroadcastManager.php:462`; `src/cache/src/CacheManager.php:422`; `src/filesystem/src/FilesystemManager.php:701`; `src/log/src/LogManager.php:562`; `src/queue/src/QueueManager.php:353` | named driver configuration | dynamic; missing names feed manager-specific errors | +| `src/bus/src/BusServiceProvider.php:68`; `src/horizon/src/Http/Controllers/BatchesController.php:59` | `queue.batching.database` | null selects the default database connection | +| `src/cache/src/CacheManager.php:65` | `cache.serializable_classes` | `false|array|null|true` union | +| `src/cache/src/CacheManager.php:158,187-221,249-310,338-372,410` | built-in cache-store options | public `build(array $config)` accepts caller-created partial records and shares these factories with named stores; shipped examples contain their required members, while documented optional members and the generic on-demand API retain their established Laravel-compatible defaults | +| `src/cache/src/Repository.php:1032` | repository store name | nullable internal value injected by CacheManager; public direct repositories may omit it | +| `src/broadcasting/src/BroadcastManager.php:378-402` | Pusher client/options/log members | public `pusher(array $config)` accepts partial runtime records; shipped Pusher/Reverb records contain their required members and omit the documented optional logging member | +| `src/reverb/src/ConfigApplicationProvider.php`; `Application.php` | optional application, rate-limiting, and webhook members | raw user-defined application records normalize once; supplied partial feature records receive documented defaults before downstream direct reads | +| `src/broadcasting/src/BroadcastManager.php:308,531` | broadcast pool control block | drivers can be made poolable at runtime, and an omitted block selects the pool API defaults | +| `src/cache/src/SwooleTableManager.php:109`; `src/rate-limiter/src/RateLimiter.php:142`; `src/rate-limiter/src/Swoole/TableManager.php:66`; `Listeners/InitializeSwooleTables.php:28`; `Listeners/RegisterPruneTimer.php:39` | named table/store config | dynamic plus package validation | +| `src/cache/src/Redis/Support/MonitoringDetector.php:33` | `telescope.enabled` | Telescope is optional and its config may not be loaded | +| `src/container/src/Attributes/Config.php:26`; `Attributes/Context.php:31`; `ContextualBindingBuilder.php:65`; `src/foundation/src/helpers.php:335` | caller-supplied keys/defaults | generic API | +| `src/console/src/GeneratorCommand.php`; `src/foundation/src/Console/PolicyMakeCommand.php` | dynamic auth provider/model lookup and empty view-path list | dynamic/null lookup; generators fall back to the application's conventional resources/views path | +| `src/database/src/Migrations/Migrator.php:617-625` | effective default and per-connection migration route | bootstrap / null / dynamic fallback | +| `src/database/src/Connectors/ConnectionFactory.php:41-147`; `Connectors/Connector.php`; connection/schema-state helpers | caller-created connection prefix/name and optional driver fields | public connection factory record or driver-specific optional input | +| `src/database/src/Console/DbCommand.php:73` | selected database connection record | a caller-supplied connection name is dynamic; the empty fallback feeds the command's `Invalid database connection` error | +| `src/encryption/src/EncryptionServiceProvider.php:93`; `Commands/KeyGenerateCommand.php:76,123`; `src/testbench/src/Foundation/Process/RemoteCommand.php:61`; `src/passkeys/config/passkeys.php:42`; `src/fortify/config/fortify.php:76`; `src/fortify/stubs/fortify.php:157` | `app.key` | null is the ungenerated-key state | +| `src/foundation/src/Bootstrap/HandleExceptions.php:123,135,145`; `src/testbench/src/Bootstrap/HandleExceptions.php:44` | dynamically created logging channels | bootstrap / dynamic | +| `src/foundation/src/Bootstrap/HandleExceptions.php`; `src/testbench/src/Bootstrap/HandleExceptions.php` | `logging.deprecations` members | optional handler-context block; Foundation defaults channel/trace to null/false, Testbench defaults trace to true, and Testbench intentionally rescues reporting failures | +| `src/foundation/src/Console/ConfigShowCommand.php:45`; `src/scout/src/EngineManager.php:264` | caller-supplied key/default | generic API | +| `src/foundation/src/Console/Kernel.php:285,293` | `app.schedule_timezone`, `cache.schedule_store` | an omitted schedule timezone selects the application timezone; a null schedule store selects the default cache | +| `src/foundation/src/Concerns/ResolvesSourceHref.php:49`; `src/foundation/resources/exceptions/renderer/components/file-with-line.blade.php:13` | `app.editor` | optional; omission or null disables editor links | +| `src/filesystem/src/FilesystemAdapter.php:104-106`; `FilesystemManager.php:138,264-365,426-434,536-667`; `FilesystemServiceProvider.php:131`; `src/support/src/Facades/Storage.php:192-195` | built-in filesystem-disk options | public constructors, builders, and creators accept caller-created partial records; arbitrary custom disks may omit the shared `serve` option; test fakes may target an unconfigured disk and preserve the public `throw` default | +| `src/routing/src/RoutingServiceProvider.php:61,91`; `src/inertia/src/Middleware.php:70-71` | `app.asset_url` | null disables the asset root/version input | +| `src/sanctum/src/Sanctum.php:54`; `src/wayfinder/src/Route.php:351`; `src/passkeys/config/passkeys.php:6`; `src/fortify/config/fortify.php:8`; `src/fortify/stubs/fortify.php:8`; `src/foundation/src/Console/DownCommand.php:80`; `AboutCommand.php:143` | `app.url` | null has explicit package behavior, renders an empty About URL, or produces a relative maintenance bypass path | +| `src/mail/resources/views/html/message.blade.php:4`; `src/mail/resources/views/text/message.blade.php:4` | `app.url` | null renders the mail header URL as empty | +| `src/foundation/src/Http/MaintenanceModeBypassCookie.php:22` | `session.domain` | null creates a host-only cookie | +| `src/grpc/src/GrpcServiceProvider.php:179` | `grpc.server.compression` | null disables compression | +| `src/horizon/src/HorizonServiceProvider.php:46,75` | `horizon.name` before normalization, `horizon.domain` | null/empty derives the application name or omits the domain | +| `src/horizon/src/Console/ListenCommand.php:36`; `HorizonCommand.php:37`; `MasterSupervisorController.php:28` | `horizon.watch`, `horizon.env` | null or empty watch paths inherit the application watcher config; null environment inherits `app.env`; the `app.env` reads on the latter two lines convert to typed access | +| `src/horizon/src/Listeners/MonitorWaitTimes.php:44-45` | `horizon.waits.{queue}` | dynamic; absent queue uses 60 seconds | +| `src/horizon/src/Console/ClearCommand.php`; `ProvisioningPlan.php`; `JobPayload.php`; `Repositories/RedisJobRepository.php`; `RedisMetricsRepository.php`; `Console/SnapshotCommand.php`; trimming/tag listeners; `Http/Controllers/DashboardStatsController.php` | optional defaults, silenced lists, job-retention members, metric-retention members, and snapshot lock | deliberately optional Horizon settings; omission uses empty lists or the owning typed numeric defaults, while recent-failed retention inherits the effective failed retention | +| `src/hashing/src/BcryptHasher.php:33-42`; `ArgonHasher.php:37-44` | direct-construction hasher options | public constructors own the algorithm defaults; shipped named records declare every member | +| `src/queue/src/Console/WorkCommand.php:363`; `ListenCommand.php:83`; `ClearCommand.php:74`; `src/horizon/src/Console/SupervisorCommand.php:144`; `ClearCommand.php:68`; `SupervisorOptions.php:78` | `queue.connections.{name}.queue` | retained fallback; callers span drivers with different fixed schemas, and `sync`, `background`, and `deferred` do not declare a queue member | +| `src/inertia/src/Commands/StartSsr.php:39`; `Ssr/BundleDetector.php:41`; `Ssr/HttpGateway.php:296` | SSR bundle/hot URL | null selects discovery or disables hot URL | +| `src/jwt/src/ClaimFactory.php:50`; `JwtManager.php:70,151,223`; `JwtServiceProvider.php:69,137` | secret, issuer, TTL, refresh TTL | null/union token behavior | +| `src/log/src/LogManager.php:570`; `src/foundation/src/Console/AboutCommand.php:176` | `logging.default` | nullable default channel | +| `src/log/src/LogManager.php:83-184,224-466` | built-in log-channel options | public `build(array $config)` and `stack()` share the named-channel construction path and accept caller-created partial records | +| `src/log/src/LogManager.php:197-214` | emergency log path | emergency logging must remain constructible when ordinary channel configuration fails | +| `src/mail/src/MailManager.php:314-332,662,674`; `src/notifications/src/Channels/SlackWebApiChannel.php:76,89,94` | mailer/address and `services.*` data | dynamic / optional credentials | +| `src/mail/src/MailManager.php:132-199,265-646` | built-in mailer transport options | public `build(array $config)` and `createSymfonyTransport(array $config)` share the named-mailer transport construction path and accept caller-created partial records | +| `src/fortify/src/FortifyServiceProvider.php:126-129,185`; `AuthenticatedSessionController.php:56-57`; `src/fortify/routes/routes.php:30,47-50` | passkey bridge, domain, pipeline, guard, and limiters | nullable / dynamic; omitted Fortify passkey overrides derive from `app.url` / `app.key` or use the Passkeys timeout default, and the optional verification limiter uses its Fortify-owned default | +| `src/passkeys/src/Passkeys.php`; `src/passkeys/routes/routes.php` | relying party, origins, secret, timeout, guard, throttle, and redirect | identity values are resolved per request or validated at use; timeout, throttle, and redirect have documented source defaults, while explicit null disables throttle and guard omission uses the current request guard | +| `src/permission/src/PermissionRegistrar.php`; `src/permission/src/Support/Config.php`; `src/permission/src/Guard.php` | optional team/default models, dynamic provider record, and guard provider | omission or null disables/selects the documented model behavior; provider discovery supports missing, Eloquent, LDAP, and custom provider records | +| `src/permission/src/PermissionRegistrar.php`; permission migrations | role/permission pivot keys, team foreign key, and cache keys | optional schema names with package-owned conventional defaults; required model/table/morph identities remain strict | +| `src/queue/src/Console/WorkCommand.php:330-333` | `queue.output_timezone` | null uses application timezone | +| `src/queue/src/QueueManager.php:301,443`; `Connectors/SqsConnector.php` | pool control block and advanced SQS SDK options | runtime-enabled pooling may omit its control block; credentials providers and SDK HTTP options are open, while version and individual HTTP timeouts retain their documented Laravel-compatible defaults | +| `src/queue/src/QueueServiceProvider.php:327-328` | file failed-job `path` and `limit` | alternate-driver members intentionally absent from the shipped database failed-job record; omission selects the file provider's documented defaults | +| `src/redis/src/RedisConfig.php`; `Pool/RedisPool.php`; `RedisProxy.php`; connection classes; `src/reverb/src/Servers/Hypervel/HypervelServerProvider.php:144` | optional advanced connection members, topology selection, URL parsing, event override, and open option/context children | the assembler owns omitted advanced-member defaults and selected-topology normalization; standalone Redis records deliberately omit the Cluster discriminator checked by Reverb; low-level consumers receive a complete effective record and use direct reads | +| `src/saloon/src/Console/Commands/MakeCommand.php:103`; `SaloonManager.php:412-434,460,562` | namespace, fixture settings, and cache/limiter stores | omission or null derives the application namespace, selects framework stores, or uses the package's fixture defaults | +| `src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php`; `SanctumServiceProvider.php`; `PersonalAccessToken.php`; `Console/Commands/PruneExpired.php` | middleware, expiration, cache store/timings | omission uses documented middleware/cache defaults; explicit null removes middleware or selects supported nullable behavior; cache timings retain package validation | +| `src/scout/src/Traits/ConfiguresJobOptions.php:42-53`; `ScoutServiceProvider.php:110-116,156`; `Console/IndexCommand.php:67-68`; `Console/SyncIndexSettingsCommand.php:50` | job options, Algolia timeouts, Meilisearch key, index settings | null delegates to the queue worker or SDK; index-setting registries are dynamic and an absent registry means there is nothing to synchronize | +| `src/foundation/src/Console/AboutCommand.php:220` | `scout.driver` | Scout is optional and its config may not be loaded; null omits it from the driver summary | +| `src/sentry/src/Features/Feature.php:115-145` | application feature-class tracing and breadcrumb keys | configurable feature registry; protected helpers retain their explicit default for application-defined keys, while every built-in key is shipped | +| `src/sentry/src/SentryConfig.php`; `SentryServiceProvider.php`; `LogChannel.php`; `Logs/LogChannel.php`; feature integrations | SDK option bag, tracing/breadcrumb groups, runtime logging/filesystem records, Redis database index | `SentryConfig::all()` normalizes optional built-in tracing/breadcrumb members and preserves open SDK options; other reads are runtime records or topology-specific values | +| `src/session/src/SessionManager.php:96,108,199,246` | connection, auth provider, block store | null / dynamic | +| `src/socialite/src/SocialiteManager.php:60-192` | provider credentials, including X alternate key | optional dynamic service config | +| `src/telescope/src/Telescope.php:194` | `horizon.path` | retained fallback; Horizon is an optional package and its config may not be loaded | +| `src/telescope/src/Telescope.php:625-628`; `TelescopeServiceProvider.php:68,203`; `Jobs/ProcessPendingUpdates.php:41,49,51`; `Http/Controllers/EntryController.php:63` | Telescope domain/queue/watcher | null / mixed / dynamic | +| `src/foundation/src/Testing/DatabaseConnectionResolver.php:211`; `Testing/Concerns/InteractsWithParallelDatabase.php:58-106`; `InteractsWithRedis.php:242`; `src/testing/src/Concerns/TestDatabases.php:172-174`; `src/testbench/src/Bootstrap/LoadConfiguration.php:28,90-107`; `Concerns/HandlesDatabases.php:78-81`; `Concerns/WithWorkbench.php`; `Foundation/Application.php:91`; `Foundation/Config.php:222,232`; `Foundation/Concerns/HandlesDatabaseConnections.php:50,75`; `Factories/UserFactory.php:59` | mutable test and Testbench YAML configuration | test harness / dynamic / null; application-defined test connections may omit pool-only `testing_enabled` | +| `src/testbench/src/Concerns/CreatesApplication.php:128` | application timezone | nullable subclass extension point | +| `src/tinker/src/Console/TinkerCommand.php:94,112,150` | `tinker.commands`, `tinker.alias`, `tinker.dont_alias`, `tinker.casters` | optional extension lists with typed empty-array fallbacks; commands and aliases remain active in config, while casters uses a commented example | +| `src/tinker/src/Console/TinkerCommand.php:54` | `trust_project` | upstream union behavior | +| `src/server/src/ServerConfig.php`; `Port.php`; `src/server-process/src/Listeners/BootProcessListener.php:26`; `src/watcher/src/Option.php`; `src/object-pool/src/PoolOptions.php` | public caller-created records | class-owned defaults are the documented runtime APIs; the server-process event also accepts runtime-created server records and process-manager-only registration; shipped named records remain complete | +| `src/foundation/src/Vite.php:344`; `src/http/src/Client/Factory.php:614`; `src/mail/src/Markdown.php:155-159` | method option and runtime connection records | public runtime APIs, not application config repositories | +| `src/http/src/Middleware/HandleCors.php:57` | CORS paths | the boot-time resolver is a public caller-record boundary and may deliberately return a partial record that disables the middleware by omitting paths | +| `src/support/src/ServiceProvider.php:169,220` | package configuration before merge | generic package-provider APIs must accept unpublished or absent application config before applying package defaults | + +After conversion, use broad greps across `src/` to inspect every remaining direct `config('...')`, `Config::get()`, config-repository `get()`, cast around config access, typed getter with a fallback, repository ArrayAccess (`$config[...]` / `config()[...]`), `has()`-then-`get()` pair, `Arr::get()` / `data_get()` on an already-loaded config array, and array-level `??` / `isset()` / `array_key_exists()` fallback applied after loading config. Also inspect map-level default injection through `array_merge()`, `array_replace()`, array union, `Arr::add()`, and `getDefault*` / normalization methods; member-level greps alone cannot find a hidden default record. Sweep public constructors plus static/non-static `build`, `fromConfig`, `fromArray`, `configure`, and `make` methods; `public( static)? function build\(` must include static builders such as `Port::build()`. For every newly declared nullable key, grep that surface across all of `src/` for presence-sensitive reads. Cross-check every statically named first-party read and every shipped named-record variant against its owning config. A stable public read may lack an active config key only when it is deliberately optional and remains discoverable through a commented config example or relevant feature documentation. An implementation read may remain undocumented only when it addresses an open-ended third-party option bag, a generic caller-supplied or runtime-created record, or an internal value created during bootstrap; config owned by an optional package may be absent until that package loads. For every nullable shipped key, verify that the owning section block or a durable leading comment explains what null does. Every remaining source literal must implement documented optional, generic, dynamic, alternate-driver, inheritance, or null behavior independently of shallow merge mechanics; an old, partial, or upstream config shape is not sufficient justification. Each remaining occurrence must match this inventory or a documented generic repository implementation. Update the inventory when a final decision changes; do not silently add exceptions. + +## Regression Coverage + +Add tests at the public behavior boundary rather than tests that merely assert which repository method was called. Every converted named registry needs at least one case that resolves through the actual shipped record without replacing it; synthetic records test consumer logic but cannot prove the shipped schema and consumer agree. + +1. **Scheduling:** in the Foundation console kernel tests, prove an omitted `app.schedule_timezone` makes scheduled events inherit `app.timezone`; a null `cache.schedule_store` leaves both `CacheEventMutex` and `CacheSchedulingMutex` on the default store; and a configured store selects it for both mutexes. Prove `SCHEDULE_CACHE_DRIVER` no longer affects configuration while `SCHEDULE_CACHE_STORE` does through the config-loading surface. +2. **Horizon:** add the shipped nullable `horizon.env` default to config coverage. Prove the Horizon command and inactive-master records use `app.env` when it is null and the configured Horizon environment when non-null; retain command-option precedence over both. The inactive-master case must persist a null environment through the real Redis repository and cover its empty-string-to-null hydration rather than faking the record; do not add a duplicate repository-only test. Existing `horizon.watch = null` and `[]` coverage must continue proving that the listen command inherits the application watcher paths. Prove omitted defaults and silenced arrays remain empty, omitted job-retention members use their independent constants, recent-failed retention inherits the configured failed retention, omitted metric-retention members preserve 24 snapshots, and an omitted snapshot lock retains the 300-second default. Keep focused coverage for the failed-tag and dashboard paths where Laravel's fallbacks disagree. +3. **Sessions:** extend `StartSessionTest` so a null `session.block_store` reaches `CacheFactory::store(null)` and the blocking request still uses the cache lock path. +4. **Mail:** render both HTML and text Markdown message views with `app.url = null`; prove the message body and application name still render and the header URL is empty rather than causing a typed-access failure. Resolve Markdown rendering with the record omitted or empty and prove the renderer-owned theme, paths, and extension defaults remain available. +5. **JWT:** extend `JwtManagerTest` with RS256 and the existing asymmetric-key fixtures while `jwt.secret` is null; encode and decode a token to prove null is valid for asymmetric signing. Keep the env-backed blacklist, refresh-issued-at, and subject-lock flags normalized to booleans for their typed consumers. +6. **Passkeys:** prove omitted timeout, throttle, and redirect settings use their source defaults, while an explicit null throttle still removes throttle middleware from login and management routes. With `app.url = null`, prove explicit relying-party/origin env values load without eager fallback failure; without those env values, prove config still loads and `relyingPartyId()` / `allowedOrigins()` raise their domain-specific errors when used. With `app.key = null`, cover both an explicit passkey secret and the domain-specific missing-secret error at use. +7. **Fortify:** cover the same explicit and absent passkey env/secret cases through provider registration, proving nullable values can cross the Fortify-to-Passkeys bridge without breaking application boot and required values still fail when a WebAuthn operation requests them. Confirm the package config and published stub expose the verification limiter and passkey timeout defaults. Prove replacement `limiters` and `passkeys` arrays may omit those optional members and receive the owning defaults, while explicit null or empty passkey values retain their domain-specific failures at use. +8. **Telescope:** prove a null dashboard path fails through the typed config boundary instead of registering unprefixed routes. Cover omission defaults for deferred storage, path/command filters, and database insertion chunk size through their existing behavior tests without duplicating generic typed-getter coverage. +9. **Permission:** prove omitted/null role and permission pivot keys, omitted team foreign key, and omitted cache keys use their owning package defaults. Align shipped literals with shared constants. Keep existing event, exception-display, schema, cache, Passport, teams, wildcard, custom-model, and required identity coverage rather than duplicating it. +10. **Sanctum:** retain explicit-null middleware removal and add grouped omission coverage for all middleware defaults. Prove omitted cache members use the two owning timing constants and documented store/prefix/enablement defaults; retain validation of explicit invalid timings. Keep required `sanctum.routes` and enabled route-prefix behavior covered. Prove an invalid non-null expiration fails before the pruning command issues its first destructive query. +11. **Scout:** retain Algolia timeout and nullable-driver coverage. Prove omitted queue enablement, chunk sizes, and Meilisearch host/retry members use their owning defaults, while active credentials and schema remain required. Align the shipped chunk literals with `Scout::DEFAULT_CHUNK_SIZE`. Keep direct typed or nullable access and remove obsolete Hyperf-era helpers. +12. **Tinker:** keep the command, alias, do-not-alias, and custom-caster lists optional with typed empty-array source defaults. Keep commands and aliases active in config and the caster example commented. Prove omission of the three active lists still executes Tinker, and prove a configured custom caster is applied during output. +13. **Fixed and driver-specific schemas:** retain failures for required guard/provider/broker, database, Redis topology, queue identity, Permission identity, Reverb identity/protocol/security, and other selected-driver members. Replace strict-era omission failures with grouped default-behavior coverage for Auth cache/broker timing, migration publishing, broadcasting Redis connection, Inertia, JWT providers, Sanctum, Scout, Permission keys, Queue retry/block/connection values, Redis optional members, Reverb nested records, and Sentry nested groups. Keep existing public-builder coverage for Cache, Filesystem, Log, and Mail. Extend the existing queue connector alignment test with retry-after rather than adding per-class tests. + Retain the existing queue transaction-boundary, failover, capability-forwarding, pooled-clearability, and bulk-delay regression suites. Do not add duplicate driver variants where the same shared branch is already covered. +14. **Legacy shapes:** prove the current migration array works and the old scalar value fails. For deprecations, prove omitted channel/trace use Foundation's null/false defaults, null channel uses the null logger, the old scalar form fails, and an unknown named channel fails at its full configuration key. Prove Testbench omission uses trace true through its real deprecation path. +15. **Sentry:** retain env typing, removed alias, Spotlight, filesystem, cache-event, and complete shipped-record coverage. Add one normalization/config-alignment test for partial tracing and breadcrumb records plus representative behavior through `SentryServiceProvider::getUserConfig()` so provider and feature paths cannot diverge. Do not add one test per boolean. +16. **Application URL:** with `app.url = null`, prove the About command reports an empty URL, the Down command reports a relative secret bypass path, console request generation uses `http://localhost`, routing reload uses the same fallback, and trusted-host discovery returns no application host. Testbench retains its existing base URL when the config value is null. Keep one focused test at each distinct boundary without duplicating generic null handling. +17. **Reverb:** retain complete shipped/integration fixtures and required identity/protocol/security failures. Add partial application, rate-limiting, and webhook records that prove constructor normalization across the config provider and direct/custom-provider boundary. Cover every downstream-consumed webhook member through one config-alignment case plus representative immediate/batched behavior; null/blank URLs remain disabled. Align activity timeout and accepted-client-event defaults with their constants. Keep env normalization and request-size coverage. +18. **Rate Limiter:** prove database connection omission selects the default database and Swoole memory-buffer omission uses 0.05. Retain configured connection, required table/Redis connection/capacity members, custom-store, and validation coverage. +19. **Saloon:** prove an omitted fixture block uses the package's conventional fixture path and permits first-use recording. Retain existing coverage for explicit facade overrides, nullable cache/rate-limiter stores, the required named HTTP connection, and generated-integration path and namespace behavior. +20. **Watcher:** align the shipped `driver` and `scan_interval` literals with the existing source defaults through `Option::fromConfig([])`. Retain its existing partial-record tests for omitted and configured values, watch-path parsing, interval validation, server executable/command validation, driver lifecycles, and process restart behavior. +21. **Environment normalization:** prove representative string values load with the declared boolean, integer, float, or nullable type at the config boundary. Finish the residual raw declarations in Reverb server/application/rate-limit/webhook numbers; hashing options; SQS overflow flags; broadcasting ports; S3 path-style mode; SMTP and Papertrail ports; database ports and SQLite foreign-key mode; Typesense numeric client settings; Telescope queue delay and size limits; and Testbench's runtime-created SQLite fallback record. Keep Sentry Spotlight uncast because `false|string` is its public union, Typesense node ports as strings because that is the SDK record shape, and the existing nullable JWT, Sanctum, and session conversions that already cast only non-null values. Public raw-record factories retain their one normalization boundary for caller-created records. +22. **Testbench inheritance:** prove its reduced app, session, rate-limiter, and auth config receives the intended framework defaults after merge, while its Eloquent provider replacement still supplies every required member. + +Existing functional coverage remains the guard for known nullable behavior, including `app.asset_url`, `app.key`, `app.editor`, `cache.serializable_classes`, `logging.default`, `session.connection`, Horizon name/domain, gRPC compression, Inertia bundle/hot URL, JWT TTL/refresh/issuer, Fortify guard/redirects, Permission nullable models/keys, Sanctum expiration/store/middleware, Saloon nullable stores, Scout job options, and queue output timezone. Do not add duplicate tests where that behavior is already directly asserted. + +Run every changed or new test file immediately from the repository root with `./vendor/bin/phpunit --no-progress `. Fix straightforward mistakes immediately; stop and investigate any behavioral contradiction or non-trivial defect before changing the contract. + +## Documentation + +Update the Configuration section of `AGENTS.md` with one sentence: `Replaceable named and nested records must apply documented optional defaults at their owning boundary; required members remain strict.` Do not add rationale or package-specific examples there. + +1. Update `src/docs/configuration.md` with repository, facade, and `config()->{type}()` examples. Explain that typed getters are for non-null settings, `get()`/direct helper reads are for meaningful null, optional, or mixed values, and required defaults belong in shipped config rather than repeated at callers. Explain that required settings fail loudly, optional public settings keep one owning fallback, and both remain discoverable. Document the merge contract: ordinary nested arrays and lists replace wholesale, while named registries merge by entry name and a matching record replaces wholesale. Replaced records must contain required identity/schema/topology/security members; documented optional members may be omitted. Public raw-record factories normalize optional defaults once. +2. Update `src/docs/passwords.md` so password brokers use explicit database/cache driver schemas. Keep `driver`, `provider`, and database `table` required. Document optional 60-minute expiry, zero throttle, database connection, and cache store behavior. Omission or null storage selectors use the corresponding default. +3. Update `src/docs/horizon.md` to document `horizon.env` / `HORIZON_ENV` as the advanced override for selecting a provisioning environment independently of `app.env`; null continues to use the application environment, and the command's `--environment` option takes precedence. Keep omission behavior for supervisor defaults, silenced lists, job retention, metric retention, and the snapshot lock concise and close to the related feature text or owning config section. +4. Update `src/docs/scheduling.md` to document `cache.schedule_store` / `SCHEDULE_CACHE_STORE` and that null selects the default cache store. +5. Update `src/docs/sentry.md` so `SENTRY_LOG_LEVEL` falls back directly to `LOG_LEVEL`; describe `SENTRY_LOGS_LEVEL` as the removed backwards-compatibility alias for the upstream rename. +6. Keep the Configuration section of `src/docs/porting-from-laravel.md` concise and action-focused. Explain wholesale replacement of fixed nested arrays and matching named records, required identity members, and documented optional omission defaults. Retain material porting decisions: explicit password-broker `driver`, Hypervel's queue `after_commit` defaults, and scheduling's `cache.schedule_store` / `SCHEDULE_CACHE_STORE`. Remove stale claims that migration publishing, deprecation members, Scout retry members, or every nested member are required. Do not add Reverb's hand-written-partial-record client-event fallback difference: both projects ship `members`, so it does not change normal porting work. +7. Update `src/docs/authentication.md` so remember-me authentication lasts until its cookie expires or the user logs out, documents the 400-day built-in lifetime, and explains the optional session guard `remember` override in minutes. Remove all three inaccurate “indefinitely” statements. +8. In the Providers section of the same guide, change the post-merge Courier singleton example to `$app->make('config')->array('courier')`. Explain that `isEnabled()` runs before the provider's own `mergeConfigFrom()` and therefore may read only already-loaded application/framework config or an intentional fallback. Use `config()->boolean('courier.enabled', false)` in that example so the optional unpublished flag is typed without throwing. +9. Update `src/docs/broadcasting.md` so the Pusher section explains the optional Pusher-compatible SDK `log` setting, its full outbound-payload logging, and its distinction from the broadcast log driver. Update the existing Reverb logging section to distinguish this client setting from Reverb server logging. +10. Update `src/docs/redis.md` so the standalone, Sentinel, and Cluster examples declare their required selected-topology members without copying optional advanced members into every record. Document omitted and explicit-null timeout/prefix/name/scheme behavior and the other optional defaults. Keep the existing Redis section of `src/docs/porting-from-laravel.md` focused on Hypervel's named topology shape, note that Laravel's `retry_interval` setting is unsupported because Hypervel always applies the complete backoff policy, and direct porters to the detailed Redis configuration documentation. +11. Update `src/docs/jwt.md` so guard examples omit `ttl` to inherit the global value and document integer overrides, null non-expiring behavior, and the one-operation `setTTL()` override. +12. Update `src/docs/fortify.md` so its passkey configuration example derives the relying party ID and allowed origins from a nullable `app.url` before building the returned array, matching the shipped config and avoiding eager fallback evaluation. In the Standalone Passkeys section, document the guard, middleware, management middleware, throttle, and redirect settings in one compact table, including their omission and null behavior. +13. Update `src/docs/providers.md` so required provider configuration is read with `config()->array()` and the conditional-provider example matches the package guide: `config()->boolean(..., false)` because `isEnabled()` runs before that provider can merge its own unpublished configuration. +14. Update `src/docs/sanctum.md` so the stateful-domain resolver reads the shipped `sanctum.stateful_domains` array without a duplicate empty fallback. +15. Update the direct custom-provider examples in `src/docs/socialite.md` according to their real boundaries. Keep `get()` for `buildOAuth2Provider()`, which deliberately accepts null and reports missing configuration itself; use `array()` for the generic provider's required `withConfig()` record. +16. Sweep the remaining documentation examples for direct config reads. Use typed access for values whose consuming example requires one type: application names, filesystem defaults, CORS and trusted-host arrays, gRPC endpoints, API-client records, mail transport credentials, and Saloon connector/OAuth credentials. Keep direct or `get()` reads in the helper/config API documentation and for examples whose null, mixed, or domain-specific missing behavior is intentional, including nullable Fortify values, generic package options, and Socialite's OAuth 2 builder boundary. +17. Update `src/docs/reverb.md` so the Application Options example remains focused and links to the dedicated Webhooks section. Document application, rate-limit, and webhook omission defaults, including the coherent safer `members` client-event default, unlimited null connections, disabled null rate limiting, disabled null/blank webhook URLs, filter null behavior, delivery retry/timing, and batching defaults. Keep the shipped/config webhook example complete and later examples clearly identified as fragments. Do not add this niche omission difference to the Laravel porting guide. +18. Port the current failover section from `examples/laravel/docs/queues.md`, then adapt it for Hypervel's shipped `database` / `deferred` record and whole-chain `after_commit` policy. Explain that the database primary is inserted after the business transaction rather than atomically inside it, the deferred fallback runs in-process at coroutine end, wrapper false leaves no-override timing to child policies, and job-level `afterCommit()` / `beforeCommit()` wins. Document relative delays from the actual post-commit publish time and absolute target times unchanged. Keep the section concise and retain the named-child worker instructions. Update SQS credential guidance for optional SDK defaults, complete static pairs, the default credential chain, named providers, and explicit fingerprints for callable or object credentials. Document the optional Redis migration batch size and the file failed-job driver's alternate settings without adding them to the shipped records. +19. Update `src/docs/cache.md` to explain that failover disables ordinary events on its outer repository because its backing repositories dispatch them, while `CacheFailedOver` remains enabled. +20. Update `src/docs/filesystem.md` with the visible S3 `root` / `AWS_ROOT` namespace, the difference between `throw` and `report`, and concise guidance for S3-compatible endpoints, path-style URLs, and provider-specific regions. Keep `AWS_*` names because they match the SDK ecosystem, and add `AWS_ROOT` to the Testbench environment example. Do not inventory every source-owned builder fallback. +21. Update `src/docs/permission.md` only for config behavior not already explained by its feature sections: document custom team-resolver and wildcard-parser contracts, explain the cache-key and column-exclusion settings, and clarify that exception-name flags affect message disclosure rather than authorization. Keep its existing detailed assignment-event section as the canonical event description. +22. Update `src/docs/logging.md` with the single/daily file-permission behavior and one compact Slack-compatible options table, including which destination and identity overrides Slack and compatible services honor. +23. Update `src/docs/mail.md` with the optional SMTP source binding and shared HTTP-client options. Keep Postmark's message-stream and client examples and the existing transport-pooling section as the discoverability surface for omitted mailer members. +24. Update `src/docs/scout.md` to distinguish an absent or PHP-null resolved driver from an omitted `SCOUT_DRIVER` environment variable, which still receives the shipped `collection` default. +25. Update Auth, JWT, Sanctum, Permission, Queue, Redis, Scout, and Sentry documentation with concise omission behavior for the optional members restored in this correction pass. Keep complete values visible in shipped config and avoid exhaustive duplicate option tables. + +Do not duplicate these docs in package READMEs. The removed Sentry env alias is package-specific and does not change a normal Laravel porting decision, so it belongs only in the Sentry documentation. + +## Implementation Order + +1. Restore or centralize the documented optional defaults for Auth, Broadcasting, Foundation, Inertia, JWT, Sanctum, Scout, Permission, Queue/Horizon, Redis, Reverb, Sentry, and Rate Limiter. Keep required identity/schema/topology/security members strict. +2. Update focused tests and user documentation with each package slice. Replace strict-era omission failures rather than layering contradictory tests on top. +3. Re-run residual-access, environment-type, nullable-presence, and public-construction-boundary greps across all of `src/`; reconcile every remaining fallback with this contract. +4. Recheck every changed config, stub, test, and documentation page, then complete the verification and review workflow below. + +## Final Verification and Review + +1. Run focused package suites for every package with changed production behavior. +2. Run `composer fix` once. It covers formatting, PHPStan, the parallel suite, and Testbench package-mode tests. Confirm the parallel suite honors `failOnWarning="true"` from `phpunit.xml.dist` and exits cleanly without passing warnings. +3. If a full check fails, use targeted checks to fix it, then run the failed step and every later step from the `fix` script. Repeat the whole command only if the correction warrants it. +4. Review every changed file and trace each config value from definition through merge semantics to consumer and test. Confirm null branches remain reachable and durably documented at their owning config surfaces, selected driver variants contain every required member without irrelevant keys, required settings fail loudly, optional settings have one documented fallback, deprecated aliases are absent, current public behavior is covered, and no duplicate fallback, stale comment, dead normalization, or workaround remains. +5. Re-run broad residual greps and `git diff --check`, then report the final result and notify the user. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a0b681e29..c21d69742 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,6 +6,7 @@ defaultTimeLimit="60" enforceTimeLimit="true" failOnRisky="true" + failOnWarning="true" processIsolation="false" stopOnError="false" stopOnFailure="false" diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php index 2ce28267b..7a3c375b9 100755 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -122,7 +122,7 @@ public function createSessionDriver(string $name, array $config): SessionGuard $guard = new SessionGuard( $name, - $this->createUserProvider($config['provider'] ?? null), + $this->createUserProvider($config['provider']), $this->app->make('session.store'), $this->app, rehashOnLogin: $repository->boolean('hashing.rehash_on_login'), @@ -152,9 +152,10 @@ public function createTokenDriver(string $name, array $config): TokenGuard // The token guard implements a basic API token based guard implementation // that takes an API token field from the request and matches it to the // user in the database or another persistence layer where users are. + // Keep the public factory defaults aligned with TokenGuard's constructor. return new TokenGuard( $name, - $this->createUserProvider($config['provider'] ?? null), + $this->createUserProvider($config['provider']), $this->app, $config['input_key'] ?? 'api_token', $config['storage_key'] ?? 'api_token', diff --git a/src/auth/src/AuthServiceProvider.php b/src/auth/src/AuthServiceProvider.php index b41c306ed..818cdd62c 100755 --- a/src/auth/src/AuthServiceProvider.php +++ b/src/auth/src/AuthServiceProvider.php @@ -236,7 +236,11 @@ private function cachedEloquentProviders(ConfigRepository $config): array $cache = $provider['cache'] ?? null; - if (! is_array($cache) || empty($cache['enabled'])) { + if ($cache === null) { + continue; + } + + if (! ($cache['enabled'] ?? false)) { continue; } @@ -258,8 +262,7 @@ private function cachedEloquentProviders(ConfigRepository $config): array ); } - // Keep this fallback aligned with CreatesUserProviders::createEloquentProvider(). - $ttl = $cache['ttl'] ?? 300; + $ttl = $cache['ttl'] ?? EloquentUserProvider::DEFAULT_CACHE_TTL; if (! is_int($ttl) || $ttl <= 0) { throw new InvalidArgumentException( diff --git a/src/auth/src/CreatesUserProviders.php b/src/auth/src/CreatesUserProviders.php index 67dda8103..1f155849c 100644 --- a/src/auth/src/CreatesUserProviders.php +++ b/src/auth/src/CreatesUserProviders.php @@ -100,19 +100,20 @@ protected function createDatabaseProvider(array $config): DatabaseUserProvider protected function createEloquentProvider(array $config): EloquentUserProvider { $provider = new EloquentUserProvider($this->app->make('hash'), $config['model']); + $cache = $config['cache'] ?? null; - if (! empty($config['cache']['enabled'])) { - $ttl = $config['cache']['ttl'] ?? 300; + if ($cache !== null && ($cache['enabled'] ?? false)) { + $ttl = $cache['ttl'] ?? EloquentUserProvider::DEFAULT_CACHE_TTL; if (! is_int($ttl) || $ttl <= 0) { throw new InvalidArgumentException('The auth user cache TTL must be a positive integer.'); } $provider->enableCache( - $config['cache']['store'] ?? null, + $cache['store'] ?? null, $ttl, - $config['cache']['prefix'] ?? 'auth_users', - $config['cache']['tags'] ?? null, + $cache['prefix'] ?? EloquentUserProvider::DEFAULT_CACHE_PREFIX, + $cache['tags'] ?? null, ); } diff --git a/src/auth/src/EloquentUserProvider.php b/src/auth/src/EloquentUserProvider.php index b439937eb..db073c9c8 100755 --- a/src/auth/src/EloquentUserProvider.php +++ b/src/auth/src/EloquentUserProvider.php @@ -19,6 +19,10 @@ class EloquentUserProvider implements UserProvider { + public const int DEFAULT_CACHE_TTL = 300; + + public const string DEFAULT_CACHE_PREFIX = 'auth_user'; + /** * The callback used to build the identifier segment of cache keys. * @@ -256,7 +260,7 @@ public function rehashPasswordIfRequired(UserContract $user, #[SensitiveParamete * by name on invalidation and avoid holding strong references. * * A null or empty-string prefix is normalized to the feature default - * ('auth_users') so misconfiguration does not create hard-to-read keys + * ('auth_user') so misconfiguration does not create hard-to-read keys * with a leading colon. * * The store is validated before any instance state is mutated, so a @@ -272,8 +276,8 @@ public function rehashPasswordIfRequired(UserContract $user, #[SensitiveParamete */ public function enableCache( ?string $storeName, - int $ttl = 300, - ?string $prefix = 'auth_users', + int $ttl = self::DEFAULT_CACHE_TTL, + ?string $prefix = self::DEFAULT_CACHE_PREFIX, ?array $tags = null, ): static { if ($ttl <= 0) { @@ -304,7 +308,7 @@ public function enableCache( $this->cache = $cache; $this->cacheStoreName = $storeName; $this->cacheTtl = $ttl; - $this->cachePrefix = $prefix === null || $prefix === '' ? 'auth_users' : $prefix; + $this->cachePrefix = $prefix === null || $prefix === '' ? self::DEFAULT_CACHE_PREFIX : $prefix; $this->registerCacheInvalidationEvents(); diff --git a/src/auth/src/Notifications/ResetPassword.php b/src/auth/src/Notifications/ResetPassword.php index 62a780e9f..35db80786 100644 --- a/src/auth/src/Notifications/ResetPassword.php +++ b/src/auth/src/Notifications/ResetPassword.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Auth\Passwords\PasswordBroker; +use Hypervel\Auth\Passwords\PasswordBrokerManager; use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Config\Repository as ConfigContract; @@ -94,7 +95,7 @@ protected function resolveExpireMinutes(): int ?? Password::getDefaultDriver(); return Container::getInstance()->make(ConfigContract::class) - ->integer("auth.passwords.{$broker}.expire", 60); + ->integer("auth.passwords.{$broker}.expire", PasswordBrokerManager::DEFAULT_EXPIRE_MINUTES); } /** diff --git a/src/auth/src/PasswordConfirmation.php b/src/auth/src/PasswordConfirmation.php index e9482b937..26aeb7ce4 100644 --- a/src/auth/src/PasswordConfirmation.php +++ b/src/auth/src/PasswordConfirmation.php @@ -30,10 +30,10 @@ public static function timeout(Repository $config, string $guard, string|int|nul $key = "auth.guards.{$guard}.password_timeout"; - if ($config->has($key)) { - return $config->integer($key); + if ($config->get($key) === null) { + return $config->integer('auth.password_timeout'); } - return $config->integer('auth.password_timeout'); + return $config->integer($key); } } diff --git a/src/auth/src/Passwords/PasswordBrokerManager.php b/src/auth/src/Passwords/PasswordBrokerManager.php index 8dc2c8845..b3ddc1756 100644 --- a/src/auth/src/Passwords/PasswordBrokerManager.php +++ b/src/auth/src/Passwords/PasswordBrokerManager.php @@ -20,6 +20,8 @@ */ class PasswordBrokerManager implements FactoryContract { + public const int DEFAULT_EXPIRE_MINUTES = 60; + /** * The coroutine context key holding the per-request default broker override. */ @@ -72,7 +74,7 @@ protected function resolve(string $name): PasswordBrokerContract // aggregate service of sorts providing a convenient interface for resets. return new PasswordBroker( $this->createTokenRepository($config), - $this->app->make('auth')->createUserProvider($config['provider'] ?? null), + $this->app->make('auth')->createUserProvider($config['provider']), $name, $this->app->bound('events') ? $this->app->make('events') : null, timeboxDuration: $this->app->make('config')->integer('auth.timebox_duration'), @@ -91,24 +93,29 @@ protected function createTokenRepository(array $config): TokenRepositoryInterfac $key = base64_decode(substr($key, 7)); } - if (isset($config['driver']) && $config['driver'] === 'cache') { - return new CacheTokenRepository( + $expire = $config['expire'] ?? self::DEFAULT_EXPIRE_MINUTES; + $throttle = $config['throttle'] ?? 0; + + return match ($config['driver']) { + 'cache' => new CacheTokenRepository( $this->app->make('cache')->store($config['store'] ?? null), $this->app->make('hash'), $key, - ($config['expire'] ?? 60) * 60, - $config['throttle'] ?? 0, - ); - } - - return new DatabaseTokenRepository( - $this->app->make('db')->connection($config['connection'] ?? null), - $this->app->make('hash'), - $config['table'], - $key, - ($config['expire'] ?? 60) * 60, - $config['throttle'] ?? 0, - ); + $expire * 60, + $throttle, + ), + 'database' => new DatabaseTokenRepository( + $this->app->make('db')->connection($config['connection'] ?? null), + $this->app->make('hash'), + $config['table'], + $key, + $expire * 60, + $throttle, + ), + default => throw new InvalidArgumentException( + "Password resetter driver [{$config['driver']}] is not defined." + ), + }; } /** @@ -133,7 +140,7 @@ public function resolveBrokerNameForGuard(UnitEnum|string $guard): ?string $config = $this->app->make('config'); $key = "auth.guards.{$guard}.passwords"; - if (! $config->has($key)) { + if ($config->get($key) === null) { return null; } diff --git a/src/auth/src/SessionGuard.php b/src/auth/src/SessionGuard.php index f16cf30dd..3d4ba331f 100644 --- a/src/auth/src/SessionGuard.php +++ b/src/auth/src/SessionGuard.php @@ -48,6 +48,10 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth /** * The number of minutes that the "remember me" cookie should be valid for. + * + * This defaults to the recommended 400-day browser cookie lifetime limit. + * + * @see https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/#section-5.5 */ protected int $rememberDuration = 576000; diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index a42353975..2bd2cb41b 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -368,7 +368,7 @@ protected function createPusherDriver(array $config): Broadcaster return new PusherBroadcaster( $this->app, $this->pusher($config), - (bool) ($config['jsonp'] ?? false), + $config['jsonp'] ?? false, ); } diff --git a/src/bus/src/UniqueJobPayloadContext.php b/src/bus/src/UniqueJobPayloadContext.php index 76b19682c..ee19009e0 100644 --- a/src/bus/src/UniqueJobPayloadContext.php +++ b/src/bus/src/UniqueJobPayloadContext.php @@ -70,7 +70,7 @@ protected static function getCacheStore(ShouldBeUnique $job): ?string { return method_exists($job, 'uniqueVia') ? $job->uniqueVia()->getName() - : config('cache.default'); + : config()->string('cache.default'); } /** diff --git a/src/cache/src/ModelCacheStoreValidator.php b/src/cache/src/ModelCacheStoreValidator.php index 9aec9e911..004774764 100644 --- a/src/cache/src/ModelCacheStoreValidator.php +++ b/src/cache/src/ModelCacheStoreValidator.php @@ -117,7 +117,7 @@ private function validateRedisStore(RedisStore $store, string $feature, array $l { $connection = $store->getContext()->connectionName(); /** @var array $options */ - $options = $this->redisConfig->connectionConfig($connection)['options'] ?? []; + $options = $this->redisConfig->connectionConfig($connection)['options']; $serializer = Redis::SERIALIZER_NONE; foreach ($options as $option => $value) { diff --git a/src/cache/src/Redis/Console/BenchmarkCommand.php b/src/cache/src/Redis/Console/BenchmarkCommand.php index f7fb4cb8f..5a31cdb4e 100644 --- a/src/cache/src/Redis/Console/BenchmarkCommand.php +++ b/src/cache/src/Redis/Console/BenchmarkCommand.php @@ -283,7 +283,7 @@ protected function confirmSafeToRun(): bool } $config = $this->hypervel->make('config'); - $env = $config->string('app.env', 'production'); + $env = $config->string('app.env'); $scale = $this->option('scale'); $this->warn('WARNING: This benchmark will put EXTREME load on your Redis instance'); @@ -567,10 +567,11 @@ protected function displayMemoryError(BenchmarkMemoryException $e): void $this->line(' php artisan cache:clear ' . $this->storeName . ''); $this->newLine(); $this->line(' Option 2 - Clear only benchmark keys (preserves other cache):'); - $cachePrefix = $config->string( - "cache.stores.{$this->storeName}.prefix", - $config->string('cache.prefix'), - ); + $prefixKey = "cache.stores.{$this->storeName}.prefix"; + $storePrefix = $config->get($prefixKey); + $cachePrefix = $storePrefix === null + ? $config->string('cache.prefix') + : $config->string($prefixKey); $this->line(' redis-cli KEYS "' . $cachePrefix . BenchmarkContext::KEY_PREFIX . '*" | xargs redis-cli DEL'); } diff --git a/src/console/src/GeneratorCommand.php b/src/console/src/GeneratorCommand.php index 96a046784..bff08873d 100644 --- a/src/console/src/GeneratorCommand.php +++ b/src/console/src/GeneratorCommand.php @@ -496,7 +496,8 @@ protected function isReservedName(string $name): bool */ protected function viewPath(string $path = ''): string { - $views = $this->hypervel->make('config')->array('view.paths')[0] ?? resource_path('views'); + $views = $this->hypervel->make('config')->array('view.paths')[0] + ?? $this->hypervel->basePath('resources/views'); return $views . ($path ? DIRECTORY_SEPARATOR . $path : $path); } diff --git a/src/contracts/src/Queue/IndexAwareQueue.php b/src/contracts/src/Queue/IndexAwareQueue.php new file mode 100644 index 000000000..76465477e --- /dev/null +++ b/src/contracts/src/Queue/IndexAwareQueue.php @@ -0,0 +1,13 @@ +factory = $container->make(PoolFactory::class); - $this->default = $container->make('config')->string('database.default', 'default'); + $this->default = $container->make('config')->string('database.default'); } /** diff --git a/src/database/src/Console/DatabaseInspectionCommand.php b/src/database/src/Console/DatabaseInspectionCommand.php index dcfce7ecb..625c92a3a 100644 --- a/src/database/src/Console/DatabaseInspectionCommand.php +++ b/src/database/src/Console/DatabaseInspectionCommand.php @@ -17,8 +17,8 @@ abstract class DatabaseInspectionCommand extends Command */ protected function getConfigFromDatabase(?string $database): array { - $database ??= config('database.default'); + $database ??= config()->string('database.default'); - return Arr::except(config('database.connections.' . $database), ['password']); + return Arr::except(config()->array('database.connections.' . $database), ['password']); } } diff --git a/src/database/src/Console/DbCommand.php b/src/database/src/Console/DbCommand.php index 7e49eaa6f..4ec6cd367 100644 --- a/src/database/src/Console/DbCommand.php +++ b/src/database/src/Console/DbCommand.php @@ -69,7 +69,7 @@ public function handle(): int public function getConnection(): array { $config = $this->hypervel->make('config'); - $connectionName = $this->argument('connection') ?? $config->string('database.default', 'default'); + $connectionName = $this->argument('connection') ?? $config->string('database.default'); $connection = $config->array("database.connections.{$connectionName}", []); if (empty($connection)) { diff --git a/src/database/src/Console/DumpCommand.php b/src/database/src/Console/DumpCommand.php index 3393f0608..a6d6dacf2 100644 --- a/src/database/src/Console/DumpCommand.php +++ b/src/database/src/Console/DumpCommand.php @@ -76,9 +76,7 @@ public function handle(ConnectionResolverInterface $connections, Dispatcher $dis */ protected function schemaState(Connection $connection): mixed { - $migrations = Config::get('database.migrations', 'migrations'); - - $migrationTable = is_array($migrations) ? ($migrations['table'] ?? 'migrations') : $migrations; + $migrationTable = Config::string('database.migrations.table'); if ($this->option('without-migration-data')) { $migrationTable = null; diff --git a/src/database/src/Console/Migrations/MigrateCommand.php b/src/database/src/Console/Migrations/MigrateCommand.php index 6d7476987..995ea5b10 100644 --- a/src/database/src/Console/Migrations/MigrateCommand.php +++ b/src/database/src/Console/Migrations/MigrateCommand.php @@ -222,7 +222,7 @@ protected function createMissingSqliteDatabase(string $path): bool protected function createMissingMySqlOrPgsqlDatabase(Connection $connection): bool { $adminConfig = (new ConfigurationUrlParser)->parseConfiguration( - $this->hypervel->make('config')->get("database.connections.{$connection->getName()}") + $this->hypervel->make('config')->array("database.connections.{$connection->getName()}") ); if (($adminConfig['database'] ?? null) !== $connection->getDatabaseName()) { diff --git a/src/database/src/Console/MonitorCommand.php b/src/database/src/Console/MonitorCommand.php index d754bf44e..256ec4cd6 100644 --- a/src/database/src/Console/MonitorCommand.php +++ b/src/database/src/Console/MonitorCommand.php @@ -67,7 +67,7 @@ protected function parseDatabases(?string $databases): Collection { return (new Collection(explode(',', $databases ?? '')))->map(function ($database) { if ($database === '') { - $database = $this->hypervel->make('config')->string('database.default', 'default'); + $database = $this->hypervel->make('config')->string('database.default'); } $maxConnections = $this->option('max'); diff --git a/src/database/src/DatabaseServiceProvider.php b/src/database/src/DatabaseServiceProvider.php index 8d2acd98b..ff5fd6b16 100644 --- a/src/database/src/DatabaseServiceProvider.php +++ b/src/database/src/DatabaseServiceProvider.php @@ -57,15 +57,9 @@ public function register(): void $this->app->singleton('db.resolver', fn ($app) => $app->make(ConnectionResolver::class)); $this->app->singleton('migration.repository', function ($app) { - $migrations = $app->make('config')->get('database.migrations'); - - $table = is_array($migrations) - ? ($migrations['table'] ?? 'migrations') - : $migrations; - return new DatabaseMigrationRepository( $app->make('db'), - $table, + $app->make('config')->string('database.migrations.table'), ); }); @@ -174,7 +168,7 @@ protected function registerFakerGenerator(): void } $this->app->scoped(FakerGenerator::class, function ($app, $parameters) { - $locale = $parameters['locale'] ?? $app->make('config')->get('app.faker_locale', 'en_US'); + $locale = $parameters['locale'] ?? $app->make('config')->string('app.faker_locale'); return FakerFactory::create($locale); }); diff --git a/src/docs/api-client.md b/src/docs/api-client.md index 9a7e7de57..061bb8b87 100644 --- a/src/docs/api-client.md +++ b/src/docs/api-client.md @@ -175,7 +175,7 @@ use App\DataObjects\GitHubConfig; $this->app->singleton(GitHubClient::class, function () { return new GitHubClient(GitHubConfig::make( - config('services.github') + config()->array('services.github') )); }); ``` diff --git a/src/docs/authentication.md b/src/docs/authentication.md index 6d98380dd..ec2e8aaed 100644 --- a/src/docs/authentication.md +++ b/src/docs/authentication.md @@ -206,17 +206,17 @@ You may enable the cache per Eloquent provider in your application's `config/aut 'driver' => 'eloquent', 'model' => env('AUTH_MODEL', App\Models\User::class), 'cache' => [ - 'enabled' => env('AUTH_USERS_CACHE_ENABLED', false), - 'store' => env('AUTH_USERS_CACHE_STORE'), - 'ttl' => (int) env('AUTH_USERS_CACHE_TTL', 300), - 'prefix' => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'), + 'enabled' => (bool) env('AUTH_USER_CACHE_ENABLED', false), + 'store' => env('AUTH_USER_CACHE_STORE'), + 'ttl' => (int) env('AUTH_USER_CACHE_TTL', 300), + 'prefix' => env('AUTH_USER_CACHE_PREFIX', 'auth_user'), 'tags' => null, ], ], ], ``` -The `ttl` value is expressed in seconds and must be a positive integer. +Omitting the `cache` record or setting it to `null` disables caching. Within a supplied record, omitted members use the displayed defaults: caching remains disabled, the default cache store is used for 300 seconds under the `auth_user` prefix, and no tags are applied. The `ttl` value must be a positive integer. Hypervel automatically allows configured provider models and its standard Eloquent collection and pivot classes to be restored from the cache. If your cached user contains application-owned relations, custom collections or pivots, or other nested objects, declare those classes from a service provider: @@ -242,15 +242,15 @@ Providers constructed directly and not represented in `auth.providers` must also When `store` is `null`, Hypervel uses your default cache store. For a single Redis-backed deployment, you may enable the cache like this: ```ini -AUTH_USERS_CACHE_ENABLED=true -AUTH_USERS_CACHE_STORE=redis +AUTH_USER_CACHE_ENABLED=true +AUTH_USER_CACHE_STORE=redis ``` For high-concurrency deployments, Hypervel's default `stack` cache store layers a short-lived Swoole Table cache over Redis. This keeps hot authenticated-user reads in local shared memory for a few seconds while Redis remains the shared backing store: ```ini -AUTH_USERS_CACHE_ENABLED=true -AUTH_USERS_CACHE_STORE=stack +AUTH_USER_CACHE_ENABLED=true +AUTH_USER_CACHE_STORE=stack ``` Supported stores are `redis`, `database`, `file`, `storage`, `swoole`, and stacks containing only supported stores. Stack layers are validated recursively. The `array`, `worker-array`, `null`, `session`, and `failover` stores are rejected. Failover is unsuitable because an unavailable primary can retain a stale identity and serve it after recovery. @@ -264,7 +264,7 @@ Auth cache configuration is read during process startup and must not be changed #### Custom Cache Keys -The default cache key format is `{prefix}:{user-model-fqcn}:{identifier}`, such as `auth_users:App\Models\User:42`. Including the model class prevents collisions when different guards use different user models. +The default cache key format is `{prefix}:{user-model-fqcn}:{identifier}`, such as `auth_user:App\Models\User:42`. Including the model class prevents collisions when different guards use different user models. If the same user identifier can resolve to different records based on request-scoped application state, you may register a cache key resolver in a service provider: @@ -324,7 +324,7 @@ If the selected guard does not use an Eloquent user provider, or if caching is d #### Bulk Invalidation -If you need to clear many cached users at once, use a dedicated cache store for auth, point `AUTH_USERS_CACHE_STORE` at that store, and flush it: +If you need to clear many cached users at once, use a dedicated cache store for auth, point `AUTH_USER_CACHE_STORE` at that store, and flush it: ```php use Hypervel\Support\Facades\Cache; @@ -352,7 +352,7 @@ For narrower bulk flushes, configure a Redis cache store in `any` tag mode and a 'enabled' => true, 'store' => 'auth', 'ttl' => 300, - 'prefix' => 'auth_users', + 'prefix' => 'auth_user', 'tags' => ['auth_users'], ], ], @@ -404,7 +404,7 @@ Cache::store('auth')->tags(['workspace:' . CurrentWorkspace::id()])->flush(); If you instantiate `EloquentUserProvider` yourself, the provider exposes lower-level cache APIs: ```php -public function enableCache(?string $storeName, int $ttl = 300, ?string $prefix = 'auth_users', ?array $tags = null): static; +public function enableCache(?string $storeName, int $ttl = 300, ?string $prefix = 'auth_user', ?array $tags = null): static; public function isCacheEnabled(): bool; public function clearUserCache(mixed $identifier): void; @@ -645,7 +645,7 @@ if (Auth::guard('admin')->attempt($credentials)) { Many web applications provide a "remember me" checkbox on their login form. If you would like to provide "remember me" functionality in your application, you may pass a boolean value as the second argument to the `attempt` method. -When this value is `true`, Hypervel will keep the user authenticated indefinitely or until they manually logout. Your `users` table must include the string `remember_token` column, which will be used to store the "remember me" token. The `users` table migration included with new Hypervel applications already includes this column: +When this value is `true`, Hypervel will keep the user authenticated until the "remember me" cookie expires or they manually log out. By default, the cookie is valid for 400 days. Your `users` table must include the string `remember_token` column, which will be used to store the "remember me" token. The `users` table migration included with new Hypervel applications already includes this column: ```php use Hypervel\Support\Facades\Auth; @@ -655,6 +655,18 @@ if (Auth::attempt(['email' => $email, 'password' => $password], $remember)) { } ``` +You may customize the cookie lifetime for a session guard using the `remember` option in your application's `config/auth.php` configuration file. The value is expressed in minutes. If this option is omitted or `null`, Hypervel uses the built-in 400-day lifetime. For example, the following configuration uses a 30-day lifetime: + +```php +'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + 'remember' => 60 * 24 * 30, + ], +], +``` + If your application offers "remember me" functionality, you may use the `viaRemember` method to determine if the currently authenticated user was authenticated using the "remember me" cookie: ```php @@ -679,7 +691,7 @@ use Hypervel\Support\Facades\Auth; Auth::login($user); ``` -You may pass a boolean value as the second argument to the `login` method. This value indicates if "remember me" functionality is desired for the authenticated session. Remember, this means that the session will be authenticated indefinitely or until the user manually logs out of the application: +You may pass a boolean value as the second argument to the `login` method. This value indicates if "remember me" functionality is desired for the authenticated session. The user will remain authenticated until the "remember me" cookie expires or they manually log out of the application: ```php Auth::login($user, $remember = true); @@ -700,7 +712,7 @@ To authenticate a user using their database record's primary key, you may use th Auth::loginUsingId(1); ``` -You may pass a boolean value to the `remember` argument of the `loginUsingId` method. This value indicates if "remember me" functionality is desired for the authenticated session. Remember, this means that the session will be authenticated indefinitely or until the user manually logs out of the application: +You may pass a boolean value to the `remember` argument of the `loginUsingId` method. This value indicates if "remember me" functionality is desired for the authenticated session. The user will remain authenticated until the "remember me" cookie expires or they manually log out of the application: ```php Auth::loginUsingId(1, remember: true); @@ -831,7 +843,7 @@ While building your application, you may occasionally have actions that should r ### Configuration -After confirming their password, a user will not be asked to confirm their password again for three hours. However, you may configure the length of time before the user is re-prompted for their password by changing the value of the `password_timeout` configuration value within your application's `config/auth.php` configuration file. Password confirmation is scoped to the current guard, so confirming under one guard never satisfies the `password.confirm` middleware under another guard. Individual guards may override the timeout with a `password_timeout` key in their guard configuration. +After confirming their password, a user will not be asked to confirm their password again for three hours. However, you may configure the length of time before the user is re-prompted for their password by changing the value of the `password_timeout` configuration value within your application's `config/auth.php` configuration file. Password confirmation is scoped to the current guard, so confirming under one guard never satisfies the `password.confirm` middleware under another guard. Individual guards may override the timeout with a `password_timeout` key in their guard configuration. If the guard option is omitted or `null`, the application-wide timeout is used. ### Routing diff --git a/src/docs/blade.md b/src/docs/blade.md index a1590f4f1..45c8434ae 100644 --- a/src/docs/blade.md +++ b/src/docs/blade.md @@ -2068,7 +2068,7 @@ use Hypervel\Support\Facades\Blade; public function boot(): void { Blade::if('disk', function (string $value) { - return config('filesystems.default') === $value; + return config()->string('filesystems.default') === $value; }); } ``` diff --git a/src/docs/broadcasting.md b/src/docs/broadcasting.md index 7e7d84b96..3a8926c86 100644 --- a/src/docs/broadcasting.md +++ b/src/docs/broadcasting.md @@ -154,6 +154,8 @@ PUSHER_APP_CLUSTER="mt1" The `config/broadcasting.php` file's `pusher` configuration also allows you to specify additional `options` that are supported by Channels, such as the cluster. +The Pusher-compatible `reverb` and `pusher` connections accept an optional `log` setting. When enabled, the Pusher SDK writes outbound broadcast requests, including serialized event payloads, to your application's default log channel. This is separate from Hypervel's `log` broadcast driver, which writes broadcast events to your application's log instead of sending them to Pusher. + Pusher JSONP responses are disabled by default. If a legacy client requires JSONP, you may explicitly enable it by setting the connection's `jsonp` option to `true`. Then, set the `BROADCAST_CONNECTION` environment variable to `pusher` in your application's `.env` file: diff --git a/src/docs/cache.md b/src/docs/cache.md index eb67fd58a..f291d1c2a 100644 --- a/src/docs/cache.md +++ b/src/docs/cache.md @@ -1167,3 +1167,5 @@ To increase performance, you may disable cache events by setting the `events` co 'events' => false, ], ``` + +The failover store disables ordinary events on its outer repository by default because its backing repositories already dispatch them. The `CacheFailedOver` event is still dispatched when a backing store fails. diff --git a/src/docs/configuration.md b/src/docs/configuration.md index a962fef06..36caef15d 100644 --- a/src/docs/configuration.md +++ b/src/docs/configuration.md @@ -8,6 +8,8 @@ - [Determining the Current Environment](#determining-the-current-environment) - [Encrypting Environment Files](#encrypting-environment-files) - [Accessing Configuration Values](#accessing-configuration-values) + - [Typed Configuration Values](#typed-configuration-values) + - [Merging Framework Defaults](#merging-framework-defaults) - [Configuration Caching](#configuration-caching) - [Configuration Publishing](#configuration-publishing) - [Debug Mode](#debug-mode) @@ -20,7 +22,7 @@ All of the configuration files for the Hypervel framework are stored in the `con These configuration files allow you to configure things like your database connection information, your mail server information, as well as various other core configuration values such as your application URL and encryption key. -Hypervel ships framework configuration defaults with the framework itself. Your application's `config` files override those defaults, while common nested options like authentication guards and providers, broadcasting connections, cache stores, database connections and Redis settings, filesystem disks, logging channels, mailers, and queue connections are merged so you may customize only the values you need. +Hypervel ships framework configuration defaults with the framework itself. Your application's `config` files override those defaults. Ordinary nested arrays and lists replace the framework value completely. Named registries such as authentication guards, cache stores, database connections, filesystem disks, log channels, mailers, and queue connections merge by entry name, but an application entry replaces the complete framework entry with the same name. > [!NOTE] > Configuration files are loaded in alphabetical order during application bootstrap. Cross-references via the `config` function depend on file load order, so you should prefer `env` or local PHP values inside configuration files. @@ -302,17 +304,44 @@ config(['app.timezone' => 'America/Chicago']); > [!WARNING] > In Hypervel's Swoole workers, runtime configuration mutations are process-global within the worker. Every concurrent coroutine in that worker may observe the changed value, so you should only mutate configuration during bootstrapping or tests. For request-specific state, use request data, middleware-managed state, or coroutine context instead. -To assist with static analysis, the `Config` facade also provides typed configuration retrieval methods. If the retrieved configuration value does not match the expected type, an exception will be thrown: + +### Typed Configuration Values + +Typed configuration methods are useful when a value must contain one particular type. These methods are available through an injected configuration repository, the `Config` facade, and the `config()` helper: + +```php +use Hypervel\Contracts\Config\Repository; +use Hypervel\Support\Facades\Config; + +function settings(Repository $config): array +{ + return [ + 'timezone' => $config->string('app.timezone'), + 'debug' => Config::boolean('app.debug'), + 'providers' => config()->array('app.providers'), + ]; +} +``` + +The available methods are `string`, `integer`, `float`, `boolean`, `array`, and `collection`. When no default is supplied, a missing key or wrong type causes Hypervel to throw an `InvalidArgumentException` that names the full configuration key. + +Some configuration values intentionally allow null or more than one type. You should retrieve these values using `get()` or the direct `config('key')` form. For example, a missing or null editor disables source links: ```php -Config::string('config-key'); -Config::integer('config-key'); -Config::float('config-key'); -Config::boolean('config-key'); -Config::array('config-key'); -Config::collection('config-key'); +$editor = config('app.editor'); ``` +Stable public options remain discoverable through their configuration file or relevant feature documentation. Optional options may appear as commented examples instead of active keys, and their documentation explains what omission or null means. Defaults for required options belong in the configuration file instead of being repeated at each read; deliberately optional settings keep a single source fallback. + + +### Merging Framework Defaults + +When your application replaces a nested configuration array, the application array replaces the entire framework array. Hypervel does not recursively merge its individual options. Therefore, your array should contain every required option from the current framework configuration; documented optional members may remain omitted. Lists are also replaced completely, so an empty application list may intentionally clear a framework list. + +For named groups such as authentication guards, cache stores, database connections, filesystem disks, log channels, mailers, and queue connections, Hypervel merges the names while replacing the contents of any matching name. For example, adding a new database connection preserves the other connections. However, replacing `database.connections.mysql` replaces the entire MySQL connection array. Start with Hypervel's provided configuration for the selected driver, then apply your changes. If a required option is missing, Hypervel will report the missing option instead of quietly supplying another default. + +This replacement behavior applies to named application configuration. Public methods such as `Cache::build()`, `Storage::build()`, `Log::build()`, and `Mail::build()` also accept arrays created directly in your application. These methods continue to supply their documented defaults when an optional value is omitted from such an array. + ## Configuration Caching diff --git a/src/docs/database.md b/src/docs/database.md index a651c206a..1c82ca494 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -118,7 +118,7 @@ To see how read / write connections should be configured, let's look at this exa ], 'sticky' => true, - 'port' => env('DB_PORT', 3306), + 'port' => (int) env('DB_PORT', 3306), 'database' => env('DB_DATABASE', 'hypervel'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index ce1cba94a..7a6d9d6ca 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -101,10 +101,13 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= +AWS_ROOT= AWS_USE_PATH_STYLE_ENDPOINT=false ``` -For convenience, these environment variables match the naming convention used by the AWS CLI. +The credential variables use the AWS SDK's standard names, while the region follows the AWS CLI convention. The remaining `AWS_*` values configure this S3 disk and work with any compatible service. + +The optional `AWS_ROOT` value scopes the disk to a key prefix within the bucket. When it is empty, the disk operates from the bucket root. #### FTP Driver Configuration @@ -125,7 +128,7 @@ Hypervel's Flysystem integrations work great with FTP; however, a sample configu 'password' => env('FTP_PASSWORD'), // Optional FTP Settings... - // 'port' => env('FTP_PORT', 21), + // 'port' => (int) env('FTP_PORT', 21), // 'root' => env('FTP_ROOT'), // 'passive' => true, // 'ssl' => true, @@ -165,7 +168,7 @@ Hypervel's Flysystem integrations work great with SFTP; however, a sample config // 'hostFingerprint' => env('SFTP_HOST_FINGERPRINT'), // 'maxTries' => 4, // 'passphrase' => env('SFTP_PASSPHRASE'), - // 'port' => env('SFTP_PORT', 22), + // 'port' => (int) env('SFTP_PORT', 22), // 'root' => env('SFTP_ROOT', ''), // 'timeout' => 30, // 'useAgent' => true, @@ -349,12 +352,14 @@ Dynamic scoped filesystems fail closed when the resolved prefix is empty. Pass ` By default, your application's `filesystems` configuration file contains a disk configuration for the `s3` disk. In addition to using this disk to interact with [Amazon S3](https://aws.amazon.com/s3/), you may use it to interact with any S3-compatible file storage service such as [RustFS](https://github.com/rustfs/rustfs), [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces/), [Vultr Object Storage](https://www.vultr.com/products/object-storage/), [Cloudflare R2](https://www.cloudflare.com/developer-platform/products/r2/), or [Hetzner Cloud Storage](https://www.hetzner.com/storage/object-storage/). -Typically, after updating the disk's credentials to match the credentials of the service you are planning to use, you only need to update the value of the `endpoint` configuration option. This option's value is typically defined via the `AWS_ENDPOINT` environment variable: +Typically, after updating the disk's credentials to match the credentials of the service you are planning to use, you will need to update the value of the `endpoint` configuration option. This option's value is typically defined via the `AWS_ENDPOINT` environment variable: ```php 'endpoint' => env('AWS_ENDPOINT', 'https://rustfs:9000'), ``` +Some S3-compatible services also require path-style URLs or a provider-specific region. Configure `AWS_USE_PATH_STYLE_ENDPOINT` and `AWS_DEFAULT_REGION` according to your storage provider's requirements. + ## Obtaining Disk Instances @@ -679,6 +684,17 @@ If you wish, you may define the `throw` option within your filesystem disk's con ], ``` +When `throw` is `false`, you may set the `report` option to `true` to report the underlying Flysystem exception through your application's exception handler while preserving the method's normal failure return value: + +```php +'public' => [ + 'driver' => 'local', + // ... + 'throw' => false, + 'report' => true, +], +``` + ### Prepending and Appending To Files diff --git a/src/docs/fortify.md b/src/docs/fortify.md index 650e83169..6148e9803 100644 --- a/src/docs/fortify.md +++ b/src/docs/fortify.md @@ -302,6 +302,8 @@ The published configuration sets those limiters to `login` and `passkeys`, and t The two-factor challenge submit route is throttled by default with `throttle:5,1`. You may set `fortify.limiters.two-factor` to a different throttle string or to a named limiter if your application needs custom keying. +Email verification and resend routes are always rate limited. They allow six requests per minute by default; omitting `fortify.limiters.verification` keeps this limit, while another throttle string or named limiter customizes it. + ```php use Hypervel\Http\Request; use Hypervel\RateLimiter\Limit; @@ -447,14 +449,27 @@ Passkey registration and deletion routes require [password confirmation](#passwo Fortify bridges these settings into the standalone Passkeys package: ```php -'passkeys' => [ - 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', parse_url(config('app.url'), PHP_URL_HOST)), - 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', [config('app.url')]), - 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), - 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60000), -], +/** @var null|string $appUrl */ +$appUrl = config('app.url'); +$defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); +$defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; + +return [ + // ... + + 'passkeys' => [ + 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', $defaultRelyingPartyId), + 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', $defaultAllowedOrigins), + 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), + 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60000), + ], +]; ``` +Each passkey member may be omitted. Fortify then uses these same application-derived identity values and a 60-second WebAuthn timeout. Explicit null or empty identity values remain explicit and are rejected when a WebAuthn operation first needs them; a configured timeout must be a positive integer. + +When your application has no canonical `app.url`, configure `PASSKEYS_RELYING_PARTY_ID` and `PASSKEYS_ALLOWED_ORIGINS` explicitly. Fortify will still boot without them, but a WebAuthn operation will reject the missing values when it first needs them. + Set `PASSKEYS_ALLOWED_ORIGINS` to a comma-separated list when WebAuthn ceremonies should be accepted from more than one origin, such as `https://example.com,https://www.example.com`. If the relying party ID or allowed origins depend on the current request, such as for custom domains or multi-tenant applications, register request-aware callbacks during boot: @@ -714,7 +729,15 @@ php artisan vendor:publish --tag=passkeys-migrations php artisan migrate ``` -Standalone routes use `passkeys.guard`, `passkeys.middleware`, `passkeys.management_middleware`, `passkeys.throttle`, and `passkeys.redirect`. +Standalone routes use the following configuration options: + +| Option | Description | +| --- | --- | +| `guard` | The guard selected for standalone routes. An omitted or null value uses the current request guard. | +| `middleware` | The required middleware applied to every standalone route. | +| `management_middleware` | The required additional middleware applied when creating or deleting passkeys. | +| `throttle` | The throttle middleware applied to passkey endpoints. Omission uses `throttle:6,1`; null disables throttling. | +| `redirect` | The successful login destination used when no intended URL exists. Omission uses `/`. | Call `Passkeys::ignoreRoutes()` during boot before registering your own endpoints: diff --git a/src/docs/grpc.md b/src/docs/grpc.md index f5280ecc2..b893e8893 100644 --- a/src/docs/grpc.md +++ b/src/docs/grpc.md @@ -617,7 +617,7 @@ use App\Grpc\Clients\GreeterClient; use Hypervel\Grpc\Client\RetryPolicy; $this->app->singleton(GreeterClient::class, fn () => new GreeterClient( - config('services.greeter.url'), + config()->string('services.greeter.url'), [ 'connect_timeout' => 3.0, 'timeout' => 5.0, diff --git a/src/docs/horizon.md b/src/docs/horizon.md index 141367563..eb51a56d3 100644 --- a/src/docs/horizon.md +++ b/src/docs/horizon.md @@ -134,6 +134,8 @@ You may also define a wildcard environment (`*`) which will be used when no othe When you start Horizon, it will use the worker process configuration options for the environment that your application is running on. Typically, the environment is determined by the value of the `APP_ENV` [environment variable](/docs/{{version}}/configuration#determining-the-current-environment). For example, the default `local` Horizon environment is configured to start three worker processes and automatically balance the number of worker processes assigned to each queue. The default `production` environment is configured to start a maximum of 10 worker processes and automatically balance the number of worker processes assigned to each queue. +For advanced deployments, the top-level `env` option or `HORIZON_ENV` environment variable may select a Horizon provisioning environment independently of `app.env`. Set it to null to inherit the application environment. The `horizon` command's `--environment` option takes precedence over both values. + > [!WARNING] > You should ensure that the `environments` portion of your `horizon` configuration file contains an entry for each [environment](/docs/{{version}}/configuration#environment-configuration) on which you plan to run Horizon. @@ -165,11 +167,15 @@ While your application is in [maintenance mode](/docs/{{version}}/configuration# Within Horizon's default configuration file, you will notice a `defaults` configuration option. This configuration option specifies the default values for your application's [supervisors](#supervisors). The supervisor's default configuration values will be merged into the supervisor's configuration for each environment, allowing you to avoid unnecessary repetition when defining your supervisors. +If the `defaults` option is omitted, Horizon applies no shared supervisor options. + ### Dashboard Authorization The Horizon dashboard may be accessed via the `/horizon` route. By default, you will only be able to access this dashboard in the `local` environment. However, within your `app/Providers/HorizonServiceProvider.php` file, there is an [authorization gate](/docs/{{version}}/authorization#gates) definition. This authorization gate controls access to Horizon in **non-local** environments. You are free to modify this gate as needed to restrict access to your Horizon installation: +The required `path` option controls the routes registered by the application. The required `proxy_path` option only prefixes URLs generated by the dashboard when a reverse proxy strips an external subdirectory before forwarding requests. Set `proxy_path` to an empty string when no external prefix is needed. + ```php /** * Register the Horizon gate. @@ -318,6 +324,8 @@ In addition to silencing individual job classes, Horizon also supports silencing ], ``` +If either silencing option is omitted, Horizon treats it as an empty list. + Alternatively, the job you wish to silence can implement the `Hypervel\Horizon\Contracts\Silenced` interface. If a job implements this interface, it will automatically be silenced, even if it is not present in the `silenced` configuration array: ```php @@ -808,6 +816,8 @@ You may configure how many snapshots Horizon retains for its metrics graphs usin ], ``` +If either retention value is omitted, Horizon keeps 24 snapshots for that metric type. The optional `metrics.snapshot_lock` value prevents overlapping snapshot runs and defaults to 300 seconds. + If you would like to delete all metric data, you can invoke the `horizon:clear-metrics` Artisan command: ```shell diff --git a/src/docs/jwt.md b/src/docs/jwt.md index e83b2a14e..1ed087cbe 100644 --- a/src/docs/jwt.md +++ b/src/docs/jwt.md @@ -224,7 +224,12 @@ After registering the driver, you may select it using the `driver` configuration The `ttl` configuration option controls how long newly issued tokens remain valid, in minutes: ```php -'ttl' => env('JWT_TTL', 120), +$ttl = env('JWT_TTL', 120); + +return [ + // ... + 'ttl' => $ttl === null ? null : (int) $ttl, +]; ``` Set this value to `null` to issue tokens without an `exp` claim: @@ -233,7 +238,7 @@ Set this value to `null` to issue tokens without an `exp` claim: 'ttl' => null, ``` -You may also configure a different TTL per guard: +JWT guards inherit the global `jwt.ttl` value when their guard configuration omits the `ttl` option. You may set a guard's `ttl` to an integer to override that value in minutes, or to `null` to issue non-expiring tokens from that guard: ```php 'guards' => [ @@ -251,6 +256,8 @@ You may also configure a different TTL per guard: ], ``` +The global `jwt.ttl` option accepts an integer or `null`. + For one token-producing operation, use `setTTL`: ```php @@ -267,7 +274,7 @@ The override is cleared after the token is generated. Subject locking is enabled by default: ```php -'lock_subject' => env('JWT_LOCK_SUBJECT', true), +'lock_subject' => (bool) env('JWT_LOCK_SUBJECT', true), ``` When subject locking is enabled and the user provider exposes its model class, JWT adds a provider hash to each token. This prevents a token issued for one provider model from authenticating against another provider model that happens to have the same ID. @@ -353,7 +360,7 @@ If your application uses timestamp validations and your servers have small clock The JWT blacklist lets the package invalidate tokens before they naturally expire: ```php -'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', false), +'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', false), ``` Blacklisting is disabled by default. When enabled, newly issued tokens include a `jti` claim and authenticated blacklist checks require cache access. Enable it when your application needs server-side token invalidation. @@ -366,6 +373,8 @@ The blacklist uses the configured storage provider: ], ``` +If the provider members are omitted, Hypervel uses `Lcobucci` for token encoding and decoding and `TaggedCache` for blacklist storage. + The default tagged-cache storage requires your default cache store to support tags. Both all-mode and any-mode tagged stores are supported. When using any-mode tags, blacklist entries are written through tags but read and removed by a private plain-key prefix. If your cache store does not support tags, implement `Hypervel\Jwt\Contracts\StorageContract` and configure your implementation using `jwt.providers.storage`. @@ -375,13 +384,18 @@ If the blacklist store uses a cache stack or any node-local tier, a revoked toke You may configure a grace period for concurrent requests that are using the same token while a refresh is in progress: ```php -'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0), +'blacklist_grace_period' => (int) env('JWT_BLACKLIST_GRACE_PERIOD', 0), ``` The `refresh_ttl` option also controls how long blacklist entries are retained. When the refresh lifetime is `null`, revocations for refreshable tokens are retained forever: ```php -'refresh_ttl' => env('JWT_REFRESH_TTL', 20160), +$refreshTtl = env('JWT_REFRESH_TTL', 20160); + +return [ + // ... + 'refresh_ttl' => $refreshTtl === null ? null : (int) $refreshTtl, +]; ``` @@ -491,13 +505,18 @@ Do not protect the refresh route with `auth:api`. Refresh must be able to read a The refresh window is controlled by `refresh_ttl`, in minutes: ```php -'refresh_ttl' => env('JWT_REFRESH_TTL', 20160), +$refreshTtl = env('JWT_REFRESH_TTL', 20160); + +return [ + // ... + 'refresh_ttl' => $refreshTtl === null ? null : (int) $refreshTtl, +]; ``` If `refresh_iat` is `false`, refreshed tokens keep the original `iat` claim. If `refresh_iat` is `true`, refreshed tokens receive a fresh `iat` claim: ```php -'refresh_iat' => env('JWT_REFRESH_IAT', false), +'refresh_iat' => (bool) env('JWT_REFRESH_IAT', false), ``` You may force the old token to remain blacklisted forever when blacklist is enabled: diff --git a/src/docs/logging.md b/src/docs/logging.md index 9602a279c..af33a6913 100644 --- a/src/docs/logging.md +++ b/src/docs/logging.md @@ -80,10 +80,12 @@ The `single` and `daily` channels have three optional configuration options: `bu | ------------ | ----------------------------------------------------------------------------- | ------- | | `bubble` | Indicates if messages should bubble up to other channels after being handled. | `true` | | `locking` | Attempt to lock the log file before writing to it. | `false` | -| `permission` | The log file's permissions. | `0644` | +| `permission` | The permissions applied to newly created log files. | `null` | +When `permission` is `null`, the operating system determines the log file's permissions. + Additionally, the retention policy for the `daily` channel can be configured via the `LOG_DAILY_DAYS` environment variable or by setting the `days` configuration option.
@@ -102,10 +104,30 @@ The default `papertrail` channel is a `monolog` channel that uses Monolog's `Sys #### Configuring the Slack Channel -The `slack` channel requires a `url` configuration option. This value may be defined via the `LOG_SLACK_WEBHOOK_URL` environment variable. This URL should match a URL for an [incoming webhook](https://slack.com/apps/A0F7XDUAZ-incoming-webhooks) that you have configured for your Slack team. +The `slack` channel requires a `url` configuration option. This value may be defined via the `LOG_SLACK_WEBHOOK_URL` environment variable. The URL may point to a [Slack incoming webhook](https://api.slack.com/messaging/webhooks) or a compatible service such as [Mattermost](https://developers.mattermost.com/integrate/webhooks/incoming/). By default, Slack will only receive logs at the `critical` level and above; however, you can adjust this using the `LOG_LEVEL` environment variable or by modifying the `level` configuration option within your Slack log channel's configuration array. +
+ +| Name | Description | Default | +| ---------------------- | -------------------------------------------------------------------------------- | ------------------ | +| `url` | The incoming webhook URL. | Required | +| `level` | The minimum level that will be sent to the webhook. | `critical` | +| `username` | The webhook username and attachment footer. | Application name | +| `emoji` | The webhook icon and attachment footer icon. | `:boom:` | +| `channel` | An optional destination override for compatible services. | `null` | +| `attachment` | Whether to format messages as attachments instead of plain text. | `true` | +| `short` | Whether to use the compact attachment layout. | `false` | +| `context` | Whether to include the record's context and extra data in its attachment. | `true` | +| `exclude_fields` | Dot-notated context and extra fields to exclude from the message. | `[]` | +| `bubble` | Whether messages should bubble to later handlers after they have been handled. | `true` | +| `replace_placeholders` | Whether placeholders in the message should be replaced using its context values. | `true` | + +
+ +Slack always uses the channel, username, and icon associated with the webhook. Mattermost uses its configured channel by default, but may honor these overrides when permitted by the server. + #### Configuring the Standard Output Channels @@ -123,7 +145,7 @@ PHP, Hypervel, and other libraries often notify their users that some of their f ```php 'deprecations' => [ 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), - 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + 'trace' => (bool) env('LOG_DEPRECATIONS_TRACE', false), ], 'channels' => [ diff --git a/src/docs/mail.md b/src/docs/mail.md index c2c38c29d..8b315a2bf 100644 --- a/src/docs/mail.md +++ b/src/docs/mail.md @@ -46,11 +46,15 @@ Hypervel's email services may be configured via your application's `config/mail. Within your `mail` configuration file, you will find a `mailers` configuration array. This array contains a sample configuration entry for each of the major mail drivers / transports supported by Hypervel, while the `default` configuration value determines which mailer will be used by default when your application needs to send an email message. +If your server has multiple network interfaces, an SMTP mailer may define a `source_ip` option to bind its outgoing connection to a specific local IP address. + ### Driver / Transport Prerequisites The API based drivers such as Mailgun, Postmark, and Resend are often simpler and faster than sending mail via SMTP servers. Whenever possible, we recommend that you use one of these drivers. +The Mailgun, Postmark, and Cloudflare transports accept an optional `client` array containing Symfony HTTP client options, such as request timeouts. + #### Cloudflare Driver @@ -926,7 +930,7 @@ View Order Thanks,
-{{ config('app.name') }} +{{ config()->string('app.name') }} ``` @@ -1683,7 +1687,7 @@ public function boot(): void new Dsn( 'brevo+api', 'default', - config('services.brevo.key') + config()->string('services.brevo.key') ) ); }); diff --git a/src/docs/notifications.md b/src/docs/notifications.md index cfa19617d..ed818213f 100644 --- a/src/docs/notifications.md +++ b/src/docs/notifications.md @@ -906,7 +906,7 @@ View Invoice Thanks,
-{{ config('app.name') }} +{{ config()->string('app.name') }} ``` diff --git a/src/docs/packages.md b/src/docs/packages.md index 6f3157fd3..b1e0cf834 100644 --- a/src/docs/packages.md +++ b/src/docs/packages.md @@ -235,10 +235,12 @@ If your package's service provider should only be loaded in some environments or */ public function isEnabled(): bool { - return (bool) config('courier.enabled'); + return config()->boolean('courier.enabled', false); } ``` +Hypervel calls `isEnabled` before the provider's `register` method, so configuration merged by that provider is not available yet. You may read configuration that the application or framework has already loaded. When an unpublished package option is intentionally optional, as in the example above, provide its fallback here. + ### Class Map Overrides @@ -312,9 +314,17 @@ public function register(): void $this->mergeConfigFrom( __DIR__.'/../config/courier.php', 'courier' ); + + $this->app->singleton(CourierManager::class, function ($app) { + return new CourierManager( + $app->make('config')->array('courier') + ); + }); } ``` +After `mergeConfigFrom` returns, the rest of the provider's `register` method may read the merged package configuration, as shown above. + > [!WARNING] > This method only merges the first level of the configuration array. If your users partially define a multi-dimensional configuration array, the missing options will not be merged. diff --git a/src/docs/passwords.md b/src/docs/passwords.md index 4417486c3..4473ea1c7 100644 --- a/src/docs/passwords.md +++ b/src/docs/passwords.md @@ -38,7 +38,7 @@ Guards that send password reset links declare their password broker with the `pa `Password::setDefaultDriver()` may override the broker for the current coroutine. Otherwise, a bare `Password::sendResetLink()` or `Password::reset()` uses the current guard's `passwords` key. If the current guard does not declare a broker, Hypervel throws a configuration exception naming the guard and the key to add. To target a different broker, pass its name explicitly with `Password::broker('admins')`. -The password reset driver defines where password reset data will be stored. If the `driver` configuration option is omitted, Hypervel will use the `database` driver. Hypervel includes two drivers: +The password reset driver defines where password reset data will be stored. Hypervel includes two drivers:
@@ -47,6 +47,20 @@ The password reset driver defines where password reset data will be stored. If t
+A database broker requires its driver, provider, and table settings. The optional `expire` and `throttle` settings default to 60 minutes and zero seconds when omitted. The example below explicitly limits token generation to once per minute. You may also define a `connection` to store password reset tokens on a specific database connection. If this option is omitted or `null`, Hypervel uses the default database connection: + +```php +'passwords' => [ + 'users' => [ + 'driver' => 'database', + 'provider' => 'users', + 'table' => 'password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], +], +``` + ### Driver Prerequisites @@ -65,14 +79,13 @@ There is also a cache driver available for handling password resets, which does 'users' => [ 'driver' => 'cache', 'provider' => 'users', - 'store' => 'passwords', // Optional... 'expire' => 60, 'throttle' => 60, ], ], ``` -To prevent a call to `artisan cache:clear` from flushing your password reset data, you can optionally specify a separate cache store with the `store` configuration key. The value should correspond to a store configured in your `config/cache.php` configuration value. +The cache driver uses the same optional expiry and throttle defaults. If the `store` option is omitted or `null`, Hypervel uses the default cache store. To prevent a call to `artisan cache:clear` from flushing your password reset data, specify a separate cache store with the `store` configuration key. The value should correspond to a store configured in your `config/cache.php` configuration file. ### Model Preparation diff --git a/src/docs/permission.md b/src/docs/permission.md index 645b784bb..60e46a43b 100644 --- a/src/docs/permission.md +++ b/src/docs/permission.md @@ -163,6 +163,8 @@ You may also customize the pivot and morph column names: ], ``` +The role and permission pivot keys may be omitted or set to `null` to use `role_id` and `permission_id`. Omitting the team foreign key uses `team_id`. Model, table, and morph-key settings remain required because they define the package schema. + ### Cache @@ -184,7 +186,7 @@ return [ ]; ``` -When `store` is `default`, the application's default cache store is used. +When `store` is omitted or set to `default`, the application's default cache store is used. The expiration defaults to 24 hours when omitted. Separate keys isolate the permission catalog, model-role assignments, direct model permissions, and the assignment namespace token so mutations can invalidate only the affected data. Omitted key members use the package names shown in the example. The `column_names_except` list removes unneeded model attributes from the cached catalog; required identity, guard, team, and partition columns cannot be excluded. You may include required role or permission names in authorization exception messages: @@ -193,6 +195,8 @@ You may include required role or permission names in authorization exception mes 'display_role_in_exception' => true, ``` +These options only change the text of `UnauthorizedException` messages. They do not change authorization results or the required names available through the exception's accessors. Keep them disabled when role or permission names are sensitive. + ## Model Setup @@ -1130,6 +1134,8 @@ Teams scope roles and role or permission assignments by a configured team foreig ], ``` +The default team resolver stores the active team ID in coroutine context. You may replace `team_resolver` with a class that implements `Hypervel\Permission\Contracts\PermissionsTeamResolver`. + Use the helpers to set the current team for the current coroutine: ```php @@ -1198,6 +1204,8 @@ $user->givePermissionTo('posts,users.create,update,view'); The wildcard permission or wildcard pattern must exist as a permission record before it can be assigned or checked. +To customize wildcard parsing, configure `wildcard_permission` with a class that implements `Hypervel\Permission\Contracts\Wildcard`. + ## Polymorphic Models diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 55b50364b..9359fcc55 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -250,7 +250,7 @@ class CourierServiceProvider extends ServiceProvider ); $this->app->singleton(Courier::class, fn ($app) => new Courier( - $app->make('config')->get('courier') + $app->make('config')->array('courier') )); } @@ -312,10 +312,12 @@ Hypervel service providers may override the `isEnabled` method to opt out of reg */ public function isEnabled(): bool { - return (bool) config('courier.enabled'); + return config()->boolean('courier.enabled', false); } ``` +Hypervel calls `isEnabled` before the provider's `register` method. Configuration merged by that provider is not available yet, so this method may only read configuration already loaded by the application or framework. The fallback above is intentional because an unpublished package option may be absent. + ### Deferred Providers @@ -451,6 +453,14 @@ protected function mergeableOptions(string $name): array } ``` +Start from Hypervel's shipped configuration files and reapply your application overrides. Fixed nested arrays are complete values. Collections such as `connections`, `stores`, and `guards` merge by entry name, but an application entry replaces the complete framework entry with the same name. Carry over required members; documented optional members may be omitted. + +When porting Laravel configuration, pay particular attention to these current differences: + +- Hypervel password broker records explicitly declare their `database` or `cache` driver. +- Hypervel's shipped background, deferred, Beanstalkd, SQS, Redis, and failover queues dispatch after commit by default; sync and database do not. A copied Laravel queue config restores Laravel's before-commit behavior. Beanstalkd records also require `port`. See the [queue guide](/docs/{{version}}/queues). +- The scheduling cache store is configured through `cache.schedule_store` and `SCHEDULE_CACHE_STORE`. Laravel's older `SCHEDULE_CACHE_DRIVER` name is not supported. + Application code should keep request-specific values in the request, session, context, or coroutine context instead of changing config values while the server is running. @@ -507,7 +517,7 @@ Database connections are persistent, pooled worker resources. Define every conne Hypervel's Redis integration uses the PhpRedis extension exclusively. Its default `config/database.php` file does not contain a `client` option or `REDIS_CLIENT` environment variable. Remove those Laravel settings when porting configuration. A copied `client` option with any value other than `phpredis` is rejected; Predis is not supported. -Laravel's top-level `database.redis.clusters` configuration is also rejected. Configure Redis Cluster by adding a `cluster` array to a named Redis connection. See the [Redis configuration](/docs/{{version}}/redis#configuration) and [cluster documentation](/docs/{{version}}/redis#clusters). +Laravel's top-level `database.redis.clusters` configuration is also rejected. Each Hypervel Redis connection selects its standalone, Sentinel, or Cluster topology within the named connection, so begin with the matching Hypervel example instead of adapting Laravel's connection shape. Optional advanced members use their documented defaults when omitted. Hypervel does not support Laravel's `retry_interval` setting; configure retries with `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap`. Configure Redis Cluster by adding a `cluster` array to a named Redis connection. See the [Redis configuration](/docs/{{version}}/redis#configuration) and [cluster documentation](/docs/{{version}}/redis#clusters). ### Cache diff --git a/src/docs/providers.md b/src/docs/providers.md index 58a7dc112..af8b58b40 100644 --- a/src/docs/providers.md +++ b/src/docs/providers.md @@ -61,7 +61,7 @@ class RiakServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(Connection::class, function (Application $app) { - return new Connection(config('riak')); + return new Connection(config()->array('riak')); }); } } @@ -253,10 +253,12 @@ You may prevent a service provider from being registered or booted by overriding */ public function isEnabled(): bool { - return (bool) config('modules.riak.enabled'); + return config()->boolean('modules.riak.enabled', false); } ``` +Hypervel calls `isEnabled` before the provider's `register` method, so configuration merged by that provider is not available yet. You may read configuration that the application or framework has already loaded. When an unpublished package option is intentionally optional, as in the example above, provide its fallback here. + When this method returns `false`, the provider's `register` and `boot` methods will not be called, its `bindings` and `singletons` properties will not be processed, and the provider will not be marked as loaded. diff --git a/src/docs/queues.md b/src/docs/queues.md index bed4a1745..9ca5f9e7e 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -75,7 +75,7 @@ While building your web application, you may have some tasks, such as parsing an Hypervel queues provide a unified queueing API across a variety of different queue backends, such as [Amazon SQS](https://aws.amazon.com/sqs/), [Redis](https://redis.io), or even a relational database. -Hypervel's queue configuration options are stored in your application's `config/queue.php` configuration file. In this file, you will find connection configurations for each of the queue drivers that are included with the framework, including the database, [Amazon SQS](https://aws.amazon.com/sqs/), [Redis](https://redis.io), and [Beanstalkd](https://beanstalkd.github.io/) drivers, as well as synchronous, background, and deferred drivers that execute jobs within the current worker process. A `null` queue driver is also included which discards queued jobs. +Hypervel's queue configuration options are stored in your application's `config/queue.php` configuration file. In this file, you will find connection configurations for each of the queue drivers that are included with the framework, including the database, [Amazon SQS](https://aws.amazon.com/sqs/), [Redis](https://redis.io), [Beanstalkd](https://beanstalkd.github.io/), and failover drivers, as well as synchronous, background, and deferred drivers that execute jobs within the current worker process. A `null` queue driver is also included which discards queued jobs. > [!NOTE] > Hypervel Horizon is a beautiful dashboard and configuration system for your Redis powered queues. Check out the full [Horizon documentation](/docs/{{version}}/horizon) for more information. @@ -122,9 +122,19 @@ Configure a connection pool inside its queue connection definition: 'driver' => 'sqs', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX'), + 'token' => env('AWS_SESSION_TOKEN'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => true, + 'overflow' => [ + 'enabled' => (bool) env('SQS_OVERFLOW_ENABLED', false), + 'store' => env('SQS_OVERFLOW_STORE'), + 'always' => false, + 'delete_after_processing' => true, + 'flush_on_clear' => (bool) env('SQS_OVERFLOW_FLUSH_ON_CLEAR', false), + ], 'pool' => [ 'min_retained_objects' => 1, 'max_objects' => 10, @@ -136,15 +146,26 @@ Configure a connection pool inside its queue connection definition: ], ``` +Hypervel uses a complete `key` and `secret` pair when both are configured, including the optional `token` as a temporary AWS session credential. When both static values are null, the AWS SDK's default credential chain is used. Configure both static values together. + +The optional `credentials` setting takes precedence and may contain an AWS credential value or a supported `ecs` or `instance` provider. If you supply callable or object credentials, set `pool.fingerprint` because these values cannot form an automatic pool identity. The optional `version` setting defaults to `latest`. Within the optional `http` array, `timeout` and `connect_timeout` each default to 60 seconds, and additional AWS SDK HTTP options are preserved. + `min_retained_objects` is an idle-trimming floor and does not eagerly connect. `max_objects` should be at least the maximum number of jobs a worker may process concurrently: a popped SQS or Beanstalkd job keeps its connection leased until `delete()`, `release()`, or `bury()` finishes. Backend failures discard the leased connection so a potentially desynchronized client is never returned to the pool. Automatic identities are sufficient for scalar and array connector configuration. Use `pool.name` for an explicit readable identity and `pool.fingerprint` when custom connector input contains an object, closure, or resource. Reusing an explicit name with a different driver, fingerprint, or normalized options fails immediately. `Queue::purge($name)` evicts the cached connection wrapper and closes its current pool. Existing jobs retain their old connection lease through their terminal backend operation; the connection is destroyed when that lease finishes. The next manager resolution creates a fresh pool. +The `queue:clear` command remains available for pooled SQS, Redis, and database connections. When making a custom clearable driver poolable, extend `QueueManager` and map that driver to `ClearableQueuePoolProxy` in the protected `$poolProxyClasses` property. + ### Driver Notes and Prerequisites + +#### Beanstalkd + +The shipped Beanstalkd connection reads its host and port from `BEANSTALKD_QUEUE_HOST` and `BEANSTALKD_QUEUE_PORT`. The optional `timeout` setting controls the connection timeout in whole seconds; when omitted or null, Pheanstalk's own default is used. Omitting `retry_after` uses Pheanstalk's 60-second time-to-run value, while omitting `block_for` disables blocking. + #### Database @@ -156,11 +177,15 @@ php artisan make:queue-table php artisan migrate ``` +The `connection` setting may be omitted or null to use the default database connection. Omitting `retry_after` uses 60 seconds. + #### Redis In order to use the `redis` queue driver, you should configure a Redis database connection in your `config/database.php` configuration file. Hypervel's Redis queue driver uses the Hypervel Redis component, which is powered by the PhpRedis extension. +The optional `migration_batch_size` connection setting limits how many delayed or expired jobs are migrated to the primary queue in one pass. When omitted, Hypervel migrates all available jobs. Omitting `connection` uses the default Redis connection, omitting `retry_after` uses 60 seconds, and omitting `block_for` disables blocking. + ##### Redis Cluster @@ -169,11 +194,11 @@ If your Redis queue connection uses a [Redis Cluster](https://redis.io/docs/late ```php 'redis' => [ 'driver' => 'redis', - 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'connection' => env('REDIS_QUEUE_CONNECTION', 'queue'), 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), 'block_for' => null, - 'after_commit' => false, + 'after_commit' => true, ], ``` @@ -189,11 +214,11 @@ Adjusting this value based on your queue load can be more efficient than continu ```php 'redis' => [ 'driver' => 'redis', - 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'connection' => env('REDIS_QUEUE_CONNECTION', 'queue'), 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), 'block_for' => 5, - 'after_commit' => false, + 'after_commit' => true, ], ``` @@ -203,25 +228,15 @@ Adjusting this value based on your queue load can be more efficient than continu #### SQS Overflow Storage -Amazon SQS limits the maximum size of a queued message payload. If you need to dispatch jobs with payloads that may exceed this limit, you may configure Hypervel to store oversized SQS payloads in a cache store and send a pointer through SQS instead. To enable this feature, add an `overflow` array to your SQS queue connection configuration: +Amazon SQS limits the maximum size of a queued message payload. If you need to dispatch jobs with payloads that may exceed this limit, you may configure Hypervel to store oversized SQS payloads in a cache store and send a pointer through SQS instead. To enable this feature, replace the `overflow` member inside your existing SQS connection with the following array: ```php -'sqs' => [ - 'driver' => 'sqs', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), - 'queue' => env('SQS_QUEUE', 'default'), - 'suffix' => env('SQS_SUFFIX'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'after_commit' => false, - 'overflow' => [ - 'enabled' => env('SQS_OVERFLOW_ENABLED', false), - 'store' => env('SQS_OVERFLOW_STORE'), - 'always' => false, - 'delete_after_processing' => true, - 'flush_on_clear' => env('SQS_OVERFLOW_FLUSH_ON_CLEAR', false), - ], +'overflow' => [ + 'enabled' => (bool) env('SQS_OVERFLOW_ENABLED', false), + 'store' => env('SQS_OVERFLOW_STORE'), + 'always' => false, + 'delete_after_processing' => true, + 'flush_on_clear' => (bool) env('SQS_OVERFLOW_FLUSH_ON_CLEAR', false), ], ``` @@ -1340,7 +1355,9 @@ When the `after_commit` option is `true`, you may dispatch jobs within database If a transaction is rolled back due to an exception that occurs during the transaction, the jobs that were dispatched during that transaction will be discarded. -After-commit work is associated with the most recently started open transaction and runs once it and every transaction enclosing it on the same connection have committed. It does not wait for transactions on other connections. If a job depends on work across connections, dispatch it only after the other dependencies have committed or while the transaction that will commit last remains open. +Hypervel's shipped configuration enables this behavior for the background, deferred, Beanstalkd, SQS, Redis, and failover connections. The sync and database connections keep it disabled. For the database driver, this allows the job row to participate in the same transaction as the data it depends on. If the queue table uses a different database connection from that data, enable `after_commit` so the job is not visible before the data commits. + +After-commit work is associated with the most recently started open transaction and runs once it and every transaction enclosing it on the same connection have committed. Ordinary queue connections do not wait for transactions on other connections. If a job depends on work across connections, dispatch it only after the other dependencies have committed or while the transaction that will commit last remains open. The failover connection is an exception: it waits for every applicable transaction. > [!NOTE] > Setting the `after_commit` configuration option to `true` will also cause any queued event listeners, mailables, notifications, and broadcast events to be dispatched after the open parent database transactions have committed. @@ -1993,29 +2010,32 @@ $user->notify($invoicePaid); The `failover` queue driver provides automatic failover functionality when pushing jobs to the queue. If the primary queue connection of the `failover` configuration fails for any reason, Hypervel will automatically attempt to push the job to the next configured connection in the list. This is particularly useful for ensuring high availability in production environments where queue reliability is critical. -To configure a failover queue connection, add a connection that uses the `failover` driver and provide an array of connection names to attempt in order: +To configure a failover queue connection, specify the `failover` driver and provide an array of connection names to attempt in order. Hypervel includes the following connection by default: ```php 'failover' => [ 'driver' => 'failover', 'connections' => [ - 'redis', 'database', - 'sync', + 'deferred', ], + 'after_commit' => true, ], ``` +With `after_commit` enabled, Hypervel waits until every applicable database transaction has committed before attempting the primary connection. A rollback discards the pending job. This keeps an after-commit primary failure inside the failover chain so the next connection is attempted. The database job insert therefore occurs after the business transaction instead of participating in it, and the default deferred fallback runs in-process at the end of the coroutine. + +If you disable the failover connection's `after_commit` setting, each child connection uses its own transaction policy. A failure from a child that defers its dispatch may then occur after the failover attempt has returned and cannot activate the next child. A job's `afterCommit()` or `beforeCommit()` choice overrides the connection setting. Integer delays begin when the post-commit attempt is made, while absolute date and time delays retain their original target. + Once you have configured a connection that uses the `failover` driver, you will need to set the failover connection as your default queue connection in your application's `.env` file to make use of the failover functionality: ```ini QUEUE_CONNECTION=failover ``` -Next, start at least one worker for each connection in your failover connection list: +Next, start a worker for each child connection that uses an external worker. With the default connection list, only the database child needs one: ```bash -php artisan queue:work redis php artisan queue:work database ``` @@ -2596,7 +2616,7 @@ In addition to running multiple worker processes, Hypervel workers can process m php artisan queue:work --concurrency=10 ``` -If the `--concurrency` option is not provided, Hypervel will use the `queue.concurrency_number` configuration value, which may be configured via the `QUEUE_CONCURRENCY_NUMBER` environment variable. +If the `--concurrency` option is not provided, Hypervel will use the `queue.concurrency` configuration value, which may be configured via the `QUEUE_CONCURRENCY` environment variable. Coroutine concurrency does not make CPU-bound jobs faster. If your jobs spend most of their time performing CPU-heavy work, you should run additional worker processes instead. diff --git a/src/docs/rate-limiting.md b/src/docs/rate-limiting.md index d9009542b..f238ccc32 100644 --- a/src/docs/rate-limiting.md +++ b/src/docs/rate-limiting.md @@ -150,7 +150,7 @@ The Swoole store allocates its table before server workers are forked. Changes t Set `rows` higher than the greatest number of rate limit keys that may be active at once. A key remains active for its fixed window, up to two sliding-window periods, its leaky-bucket refill time, or its backoff inactivity time. For example, if up to 40,000 client IP addresses may have active one-minute limits at once, configure substantially more than 40,000 rows. -Swoole rounds `rows` up to a power of two with a minimum of 64 and allocates an additional collision area based on `conflict_proportion`. Hash collisions may exhaust that collision area before the table's total row count reaches its configured size. Hypervel logs a warning when table or collision pressure enters the configured `memory_limit_buffer`, and throws `Hypervel\RateLimiter\Exceptions\SwooleTableFullException` if a live entry cannot be allocated. It never evicts active limiter state because doing so could admit excess traffic. +Swoole rounds `rows` up to a power of two with a minimum of 64 and allocates an additional collision area based on `conflict_proportion`. Hash collisions may exhaust that collision area before the table's total row count reaches its configured size. Hypervel logs a warning when table or collision pressure enters the configured `memory_limit_buffer`, which defaults to `0.05` when omitted, and throws `Hypervel\RateLimiter\Exceptions\SwooleTableFullException` if a live entry cannot be allocated. It never evicts active limiter state because doing so could admit excess traffic. Worker zero prunes expired rows at the configured `prune_interval`, in seconds. Consuming capacity, recording a failure, or clearing a key also replaces or removes expired state for that key. Inspection treats expired state as empty without changing the table. diff --git a/src/docs/redis.md b/src/docs/redis.md index 0aaea26f0..584a15253 100644 --- a/src/docs/redis.md +++ b/src/docs/redis.md @@ -68,119 +68,66 @@ You may configure your application's Redis settings via the `config/database.php 'max_lifetime' => (float) env('REDIS_MAX_LIFETIME', -1), ], ], - - 'cache' => [ - 'url' => env('REDIS_CACHE_URL', env('REDIS_URL')), - 'host' => env('REDIS_CACHE_HOST', env('REDIS_HOST', 'localhost')), - 'username' => env('REDIS_CACHE_USERNAME', env('REDIS_USERNAME')), - 'password' => env('REDIS_CACHE_PASSWORD', env('REDIS_PASSWORD')), - 'port' => (int) env('REDIS_CACHE_PORT', env('REDIS_PORT', 6379)), - 'database' => (int) env('REDIS_CACHE_DB', env('REDIS_DB', 0)), - 'max_retries' => (int) env('REDIS_CACHE_MAX_RETRIES', env('REDIS_MAX_RETRIES', 3)), - 'backoff_algorithm' => env('REDIS_CACHE_BACKOFF_ALGORITHM', env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter')), - 'backoff_base' => (int) env('REDIS_CACHE_BACKOFF_BASE', env('REDIS_BACKOFF_BASE', 100)), - 'backoff_cap' => (int) env('REDIS_CACHE_BACKOFF_CAP', env('REDIS_BACKOFF_CAP', 1000)), - 'pool' => [ - 'min_connections' => (int) env('REDIS_CACHE_MIN_CONNECTIONS', env('REDIS_MIN_CONNECTIONS', 1)), - 'max_connections' => (int) env('REDIS_CACHE_MAX_CONNECTIONS', env('REDIS_MAX_CONNECTIONS', 10)), - 'connect_timeout' => 10.0, - 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('REDIS_CACHE_HEARTBEAT', env('REDIS_HEARTBEAT', -1)), - 'heartbeat_timeout' => (float) env('REDIS_CACHE_HEARTBEAT_TIMEOUT', env('REDIS_HEARTBEAT_TIMEOUT', 1.0)), - 'max_idle_time' => (float) env('REDIS_CACHE_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60)), - 'max_lifetime' => (float) env('REDIS_CACHE_MAX_LIFETIME', env('REDIS_MAX_LIFETIME', -1)), - ], - ], ], ``` -Hypervel's default configuration also includes `session`, `queue`, and `reverb` Redis connections. Each connection follows the same shape as the `default` and `cache` connections shown above. +Hypervel's default configuration also includes `cache`, `session`, `queue`, and `reverb` Redis connections. Each connection follows the same standalone shape as the `default` connection shown above. -Each standalone Redis server defined in your configuration file is required to have a name, host, and port unless you define a single URL to represent the Redis connection: +Each named connection selects a standalone, Sentinel, or Cluster topology. Every topology may omit `scheme`, `username`, `password`, `timeout`, `read_timeout`, `context`, `options`, `prefix`, `events`, `max_retries`, `backoff_algorithm`, `backoff_base`, `backoff_cap`, and `pool`. An omitted `read_timeout` uses `0.0`, `context` and per-connection `options` use empty arrays, and `events` is disabled. Standalone and Sentinel connections may also omit `database` to use database `0`, or omit `name`. -```php -'redis' => [ - 'options' => [ - 'prefix' => env('REDIS_PREFIX', app_id() . ':'), - ], +For standalone and Sentinel connections, an omitted or null `name` disables `CLIENT SETNAME`. An omitted or null `timeout` uses the connection pool's `connect_timeout`, while an omitted or null `prefix` inherits the shared `redis.options.prefix` value. An omitted or null `scheme` leaves transport selection to the connection URL, stream context, or the default TCP transport. Cluster connections derive their transport from their seeds and context when `scheme` is omitted and do not have a client name. - 'default' => [ - 'url' => 'tcp://127.0.0.1:6379?database=0', - ], +You may define a single URL for a standalone connection instead of configuring its endpoint and credentials separately. Set the URL through the corresponding environment variable: - 'cache' => [ - 'url' => 'tls://user:password@127.0.0.1:6380?database=0', - ], -], +```ini +REDIS_URL="tcp://127.0.0.1:6379?database=0" +REDIS_CACHE_URL="tls://user:password@127.0.0.1:6380?database=0" ``` #### Configuring the Connection Scheme -By default, Redis connections will use the `tcp` scheme when connecting to your Redis servers. However, you may use TLS / SSL encryption by specifying a `scheme` configuration option in your Redis server's configuration array: +By default, Redis connections will use the `tcp` scheme when connecting to your Redis servers. However, you may use TLS / SSL encryption by adding a `scheme` member to the connection: ```php -'default' => [ - 'scheme' => 'tls', - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', 'localhost'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => (int) env('REDIS_PORT', 6379), - 'database' => (int) env('REDIS_DB', 0), -], +'scheme' => 'tls', ``` -When no scheme is specified, a non-empty `context` configuration also selects TLS. +When `scheme` is omitted or null, a non-empty `context` configuration selects TLS. ### PhpRedis -Hypervel communicates with Redis using the PhpRedis extension. In addition to the default configuration options, Hypervel supports the following connection parameters: `url`, `scheme`, `host`, `username`, `password`, `port`, `database`, `name`, `timeout`, `retry_interval`, `read_timeout`, `context`, `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap`. +Hypervel communicates with Redis using the PhpRedis extension. Standalone connections support the following parameters: `url`, `scheme`, `host`, `username`, `password`, `port`, `database`, `name`, `timeout`, `read_timeout`, `context`, `options`, `prefix`, `events`, `max_retries`, `backoff_algorithm`, `backoff_base`, `backoff_cap`, and `pool`. ```php -'default' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', 'localhost'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => (int) env('REDIS_PORT', 6379), - 'database' => (int) env('REDIS_DB', 0), - 'timeout' => 5.0, - 'retry_interval' => 0, - 'read_timeout' => 60, - 'name' => 'hypervel', - 'context' => [ - // 'stream' => ['verify_peer' => false], - ], +'timeout' => 5.0, +'read_timeout' => 60, +'name' => 'hypervel', +'context' => [ + // 'stream' => ['verify_peer' => false], ], ``` -The `read_timeout` value is applied both when the Redis socket is opened and as the PhpRedis `Redis::OPT_READ_TIMEOUT` option. The optional `name` value sets the client name on standalone Redis connections. +A non-zero `read_timeout` is applied both when the Redis socket is opened and as the PhpRedis `Redis::OPT_READ_TIMEOUT` option. A value of `0.0` leaves PHP's `default_socket_timeout` in effect. The optional `name` value sets the client name on standalone Redis connections. -The `context` option accepts stream options directly or nested under an `ssl` or `stream` key. If you need to configure PhpRedis options such as `prefix`, `scan`, `serializer`, `compression`, `compression_level`, `tcp_keepalive`, or `pack_ignore_numbers`, add them to the `options` array. The `pack_ignore_numbers` option requires PhpRedis 6.2 or later and applies only to standalone connections. Connection options override shared options, while a top-level connection `prefix` takes final precedence. +The `context` option accepts stream options directly or nested under an `ssl` or `stream` key. If you need to configure PhpRedis options such as `prefix`, `scan`, `serializer`, `compression`, `compression_level`, `tcp_keepalive`, or `pack_ignore_numbers`, add them to the `options` array. The `pack_ignore_numbers` option requires PhpRedis 6.2 or later and applies only to standalone connections. Connection options override shared options, while a non-null top-level connection `prefix` takes final precedence. #### Retry and Backoff Configuration -The `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap` options may be used to configure how PhpRedis backs off between retry attempts. The following backoff algorithms are supported: `default`, `decorrelated_jitter`, `equal_jitter`, `exponential`, `uniform`, and `constant`: +The `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap` members configure how PhpRedis backs off between retry attempts. The following backoff algorithms are supported: `default`, `decorrelated_jitter`, `equal_jitter`, `exponential`, `uniform`, and `constant`: ```php -'default' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', 'localhost'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => (int) env('REDIS_PORT', 6379), - 'database' => (int) env('REDIS_DB', 0), - 'max_retries' => (int) env('REDIS_MAX_RETRIES', 3), - 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), - 'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), - 'backoff_cap' => (int) env('REDIS_BACKOFF_CAP', 1000), -], +'max_retries' => (int) env('REDIS_MAX_RETRIES', 3), +'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), +'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), +'backoff_cap' => (int) env('REDIS_BACKOFF_CAP', 1000), ``` -These settings control PhpRedis' native connection retry behavior. Hypervel does not replay a failed command because Redis may already have committed it before the failure became visible to the client. +These settings control PhpRedis' native connection retry behavior. Hypervel applies the complete retry policy to every connection before issuing commands. Hypervel does not replay a failed command because Redis may already have committed it before the failure became visible to the client. + +When omitted, `max_retries` uses `3`, `backoff_algorithm` uses `decorrelated_jitter`, `backoff_base` uses `100`, and `backoff_cap` uses `1000`. The environment variables shown above only apply to connection records that declare these settings. #### Unix Socket Connections @@ -224,9 +171,20 @@ If your application is utilizing Redis Cluster, you should define a `cluster` ar 'default' => [ 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), - 'timeout' => 5.0, - 'read_timeout' => 5.0, - 'context' => [], + 'max_retries' => (int) env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => (int) env('REDIS_BACKOFF_CAP', 1000), + 'pool' => [ + 'min_connections' => (int) env('REDIS_MIN_CONNECTIONS', 1), + 'max_connections' => (int) env('REDIS_MAX_CONNECTIONS', 10), + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => (float) env('REDIS_HEARTBEAT', -1), + 'heartbeat_timeout' => (float) env('REDIS_HEARTBEAT_TIMEOUT', 1.0), + 'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60), + 'max_lifetime' => (float) env('REDIS_MAX_LIFETIME', -1), + ], 'cluster' => [ 'enabled' => true, 'seeds' => explode(',', env('REDIS_CLUSTER_SEEDS', '127.0.0.1:6379')), @@ -234,7 +192,7 @@ If your application is utilizing Redis Cluster, you should define a `cluster` ar ], ``` -The `seeds` option should contain one or more `host:port` entries for nodes in the cluster. Redis Cluster does not support selecting logical databases, so the `database` option is ignored for clustered connections. +The `seeds` option should contain one or more `host:port` entries for nodes in the cluster. Redis Cluster does not support selecting logical databases, so Cluster records do not contain a `database` member. They also omit the standalone-only `url`, `host`, `port`, and `name` members. All nodes in a cluster connection must use the same transport. You may select TLS using the top-level `scheme` option, a `tls://` or `ssl://` seed, or a non-empty top-level `context` array. Bare seeds inherit the selected transport. Hypervel rejects conflicting schemes because PhpRedis applies one stream context to every node it discovers. @@ -264,22 +222,15 @@ Redis Sentinel provides high availability for Redis by monitoring your Redis mas 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), 'database' => (int) env('REDIS_DB', 0), - 'timeout' => 5.0, - 'retry_interval' => 0, - 'read_timeout' => 5.0, 'sentinel' => [ 'enabled' => true, 'master_name' => env('REDIS_SENTINEL_MASTER', 'mymaster'), 'nodes' => explode(',', env('REDIS_SENTINEL_NODES', '127.0.0.1:26379')), - 'username' => env('REDIS_SENTINEL_USERNAME'), - 'password' => env('REDIS_SENTINEL_PASSWORD'), - 'timeout' => 5.0, - 'read_timeout' => 5.0, ], ], ``` -When Sentinel is enabled, Hypervel asks Sentinel for the current master address and then connects to that Redis master. The `username` and `password` values in the `sentinel` array authenticate with Sentinel itself. Redis authentication still uses the connection's top-level `username` and `password` values. The nested `timeout`, `read_timeout`, and `context` values configure Sentinel discovery, while their top-level counterparts configure the resolved Redis connection. The top-level `retry_interval` value applies only to the resolved Redis connection. +When Sentinel is enabled, Hypervel asks Sentinel for the current master address and then connects to that Redis master. You may add `username` and `password` to the `sentinel` array to authenticate with Sentinel itself; Redis authentication still uses the connection's top-level values. Optional nested `timeout` and `read_timeout` values default to `0.0`, while `context` defaults to an empty array. Sentinel nodes may use `tcp://` or `tls://` schemes. IPv6 addresses must use brackets, including when TLS is enabled: @@ -320,6 +271,8 @@ Hypervel pools Redis connections so commands can reuse established sockets acros ], ``` +When the `pool` array is omitted, Hypervel uses a managed-connection floor of one and allows up to 10 connections, with 10-second connection, three-second wait, and 60-second idle timeouts. Heartbeats and maximum-lifetime recycling are disabled, and the heartbeat timeout is one second. The environment variables shown above only apply to connection records that declare a `pool` array. + The `min_connections` option controls how far trimming excess idle connections may reduce the total managed connection count. It is not an idle-count invariant or a guaranteed total minimum, and it does not prewarm or automatically replenish the pool. The caller that first needs each new connection pays its connection-establishment cost, and the pool may have zero idle connections under load. Lifecycle-expired or unhealthy connections and explicit discards can reduce the managed count below `min_connections`; failed connection creation can leave it below that value. None is automatically replenished. The `max_connections` option caps the number of connections the worker may open. The `connect_timeout` option controls how long Hypervel will wait while opening a new Redis connection. The `wait_timeout` option controls how long a coroutine may wait for a pooled connection to become available. The `heartbeat` option controls how often Hypervel validates idle connections in the worker pool; set this value to `-1` to disable background heartbeats. The `heartbeat_timeout` option controls how long a heartbeat ping may run before the connection is discarded. The `max_idle_time` option controls how long an idle connection may remain reusable while the total managed count is above `min_connections`, and the `max_lifetime` option controls the upper bound for how long a pooled connection generation may live before it is recycled while idle or before it is reused; Hypervel assigns each generation an effective lifetime between 90-100% of this value to avoid synchronized reconnects. Set `max_lifetime` to `-1` to disable lifetime recycling. Idle and lifetime recycling are checked when a connection is borrowed from the pool. When heartbeat is enabled, Hypervel also runs a background sweep over idle pooled Redis connections so stale sockets are found before a request needs them. Heartbeat and max lifetime recycling apply to Hypervel's worker pool whether the connection points directly at Redis, a managed Redis service, or a proxy. diff --git a/src/docs/requests.md b/src/docs/requests.md index 6d6684067..2d01d9031 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -953,7 +953,7 @@ If you need to access your application's configuration files or database to dete use Hypervel\Foundation\Configuration\Middleware; ->withMiddleware(function (Middleware $middleware): void { - $middleware->trustHosts(at: fn () => config('app.trusted_hosts')); + $middleware->trustHosts(at: fn () => config()->array('app.trusted_hosts')); }) ``` diff --git a/src/docs/reverb.md b/src/docs/reverb.md index 0edf0bdd3..420f8cc3f 100644 --- a/src/docs/reverb.md +++ b/src/docs/reverb.md @@ -118,40 +118,46 @@ For example, you may wish to maintain a single Hypervel application which, via R ### Application Options -Each application may also define client connection options, allowed origins, connection limits, message limits, client-event behavior, and message rate limiting: +Each application may also define client connection options, allowed origins, connection limits, message limits, client-event behavior, and message rate limiting. Webhook configuration is covered in the dedicated [Webhooks](#webhooks) section: ```php -'apps' => [ - 'provider' => 'config', +$maxConnections = env('REVERB_APP_MAX_CONNECTIONS'); +return [ 'apps' => [ - [ - 'app_id' => env('REVERB_APP_ID'), - 'key' => env('REVERB_APP_KEY'), - 'secret' => env('REVERB_APP_SECRET'), - 'options' => [ - 'host' => env('REVERB_HOST'), - 'port' => env('REVERB_PORT', 443), - 'scheme' => env('REVERB_SCHEME', 'https'), - 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', - ], - 'allowed_origins' => ['*'], - 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), - 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), - 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), - 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), - 'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'), - 'rate_limiting' => [ - 'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false), - 'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60), - 'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60), - 'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false), + 'provider' => 'config', + + 'apps' => [ + [ + 'app_id' => env('REVERB_APP_ID'), + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => (int) env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'allowed_origins' => ['*'], + 'ping_interval' => (int) env('REVERB_APP_PING_INTERVAL', 60), + 'activity_timeout' => (int) env('REVERB_APP_ACTIVITY_TIMEOUT', 30), + 'max_connections' => $maxConnections === null ? null : (int) $maxConnections, + 'max_message_size' => (int) env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + 'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'), + 'rate_limiting' => [ + 'enabled' => (bool) env('REVERB_APP_RATE_LIMIT_ENABLED', false), + 'max_attempts' => (int) env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60), + 'decay_seconds' => (int) env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60), + 'terminate_on_limit' => (bool) env('REVERB_APP_RATE_LIMIT_TERMINATE_ON_LIMIT', false), + ], ], ], ], -], +]; ``` +When using the `config` application provider, omitting `activity_timeout` uses 30 seconds, omitting `max_connections` allows unlimited connections, omitting `accept_client_events_from` uses `members`, and omitting `options` uses an empty array. Omitting `rate_limiting` or `webhooks` disables that feature. A null `max_connections` value also allows unlimited connections. + The `accept_client_events_from` option controls which connections may send client events. The default value, `members`, allows client events from connections subscribed to the target private or presence channel. You may use `all` to allow client events from any connection, or any other value to disable client events. The `max_message_size` option limits the size of each WebSocket message sent by a connected client. Reverb also has a server-level `max_request_size` option that limits HTTP API request bodies sent to the Reverb port: @@ -159,13 +165,13 @@ The `max_message_size` option limits the size of each WebSocket message sent by ```php 'servers' => [ 'reverb' => [ - 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'max_request_size' => (int) env('REVERB_MAX_REQUEST_SIZE', 10_000), // ... ], ], ``` -The `rate_limiting` option limits messages received from each connected client, including Pusher protocol messages such as `pusher:subscribe` and `pusher:ping`. Rate limiting is applied only when `enabled` is `true`. Each connection may send up to `max_attempts` messages during the configured `decay_seconds` period. When this limit is exceeded, Reverb returns error code 4301. Setting `terminate_on_limit` to `true` sends the error and then terminates the connection. +The `rate_limiting` option limits messages received from each connected client, including Pusher protocol messages such as `pusher:subscribe` and `pusher:ping`. Rate limiting is applied only when `enabled` is `true`. Each connection may send up to `max_attempts` messages during the configured `decay_seconds` period. When this limit is exceeded, Reverb returns error code 4301. Setting `terminate_on_limit` to `true` sends the error and then terminates the connection. Within a supplied rate-limiting record, omitted members default to disabled, 60 attempts, a 60-second decay period, and no forced disconnect. ### SSL @@ -178,7 +184,7 @@ However, Reverb may also terminate TLS directly. To do so, configure TLS options 'servers' => [ 'reverb' => [ 'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), - 'port' => env('REVERB_SERVER_PORT', 8080), + 'port' => (int) env('REVERB_SERVER_PORT', 8080), 'options' => [ 'tls' => [ 'local_cert' => '/path/to/cert.pem', @@ -253,6 +259,8 @@ public function register(): void } ``` +Logging outgoing broadcasts is configured separately. Set the optional `log` setting on the `reverb` connection in `config/broadcasting.php` to write outbound SDK requests, including serialized event payloads, to your application's default log channel. + ### Restarting @@ -276,7 +284,7 @@ If your application uses [Telescope](/docs/{{version}}/telescope), Hypervel incl use Hypervel\Telescope\Watchers; Watchers\ReverbWatcher::class => [ - 'enabled' => env('TELESCOPE_REVERB_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_REVERB_WATCHER', true), 'events' => [ 'connection_established', 'connection_closed', @@ -286,7 +294,7 @@ Watchers\ReverbWatcher::class => [ // 'message_received', // 'message_sent', ], - 'message_size_limit' => env('TELESCOPE_REVERB_MESSAGE_SIZE_LIMIT', 64), + 'message_size_limit' => (int) env('TELESCOPE_REVERB_MESSAGE_SIZE_LIMIT', 64), ], ``` @@ -342,13 +350,25 @@ To enable webhooks, configure a webhook URL and the events you would like to rec 'channel_name_ends_with' => env('REVERB_WEBHOOK_CHANNEL_SUFFIX'), ], - 'subscription_count' => env('REVERB_WEBHOOK_SUBSCRIPTION_COUNT', false), - 'disconnect_smoothing_ms' => env('REVERB_WEBHOOK_DISCONNECT_SMOOTHING_MS', 3000), + 'subscription_count' => (bool) env('REVERB_WEBHOOK_SUBSCRIPTION_COUNT', false), + 'disconnect_smoothing_ms' => (int) env('REVERB_WEBHOOK_DISCONNECT_SMOOTHING_MS', 3000), + 'timeout' => (int) env('REVERB_WEBHOOK_TIMEOUT', 5), + 'retries' => (int) env('REVERB_WEBHOOK_RETRIES', 3), + 'retry_delay' => (int) env('REVERB_WEBHOOK_RETRY_DELAY', 1), + + 'batching' => [ + 'enabled' => (bool) env('REVERB_WEBHOOK_BATCHING_ENABLED', false), + 'max_events' => (int) env('REVERB_WEBHOOK_BATCHING_MAX_EVENTS', 50), + 'max_delay_ms' => (int) env('REVERB_WEBHOOK_BATCHING_MAX_DELAY_MS', 250), + 'max_payload_bytes' => (int) env('REVERB_WEBHOOK_BATCHING_MAX_PAYLOAD_BYTES', 262144), + ], ], ``` The `events` array acts as an allowlist. If the array is empty, Reverb will send all supported webhook events except `subscription_count`. The `subscription_count` event is controlled by its own configuration option and does not need to be listed in the `events` array. The `disconnect_smoothing_ms` option delays `channel_vacated` and `member_removed` webhooks after a disconnect so brief reconnects do not produce unnecessary remove / add webhook pairs. +A null, empty, or omitted `url` disables webhooks. Omitted `events` and `headers` use empty arrays, while omitted channel filters use null and match any channel. Subscription-count delivery defaults to disabled and disconnect smoothing defaults to 3,000 milliseconds. Delivery defaults to a five-second timeout, three retries, and a one-second retry delay. An omitted batching record disables batching and uses limits of 50 events, 250 milliseconds, and 262,144 bytes when batching is later enabled. + ### Delivery and Signing @@ -365,18 +385,14 @@ Custom headers may be configured, but `X-Pusher-Key`, `X-Pusher-Signature`, and ### Batching -For production workloads, you may enable webhook batching to combine many events into fewer HTTP requests: +For production workloads, you may enable webhook batching to combine many events into fewer HTTP requests. The following members belong within the application's existing `webhooks` array: ```php -'webhooks' => [ - 'url' => env('REVERB_WEBHOOK_URL'), - - 'batching' => [ - 'enabled' => env('REVERB_WEBHOOK_BATCHING_ENABLED', false), - 'max_events' => env('REVERB_WEBHOOK_BATCHING_MAX_EVENTS', 50), - 'max_delay_ms' => env('REVERB_WEBHOOK_BATCHING_MAX_DELAY_MS', 250), - 'max_payload_bytes' => env('REVERB_WEBHOOK_BATCHING_MAX_PAYLOAD_BYTES', 262144), - ], +'batching' => [ + 'enabled' => (bool) env('REVERB_WEBHOOK_BATCHING_ENABLED', false), + 'max_events' => (int) env('REVERB_WEBHOOK_BATCHING_MAX_EVENTS', 50), + 'max_delay_ms' => (int) env('REVERB_WEBHOOK_BATCHING_MAX_DELAY_MS', 250), + 'max_payload_bytes' => (int) env('REVERB_WEBHOOK_BATCHING_MAX_PAYLOAD_BYTES', 262144), ], ``` @@ -385,15 +401,12 @@ When batching is enabled, Reverb buffers events in Redis and schedules flush job ### Failure Handling -You may configure webhook delivery timeouts and retry behavior using the `timeout`, `retries`, and `retry_delay` options: +You may configure webhook delivery timeouts and retry behavior using the following members within the application's existing `webhooks` array: ```php -'webhooks' => [ - 'url' => env('REVERB_WEBHOOK_URL'), - 'timeout' => env('REVERB_WEBHOOK_TIMEOUT', 5), - 'retries' => env('REVERB_WEBHOOK_RETRIES', 3), - 'retry_delay' => env('REVERB_WEBHOOK_RETRY_DELAY', 1), -], +'timeout' => (int) env('REVERB_WEBHOOK_TIMEOUT', 5), +'retries' => (int) env('REVERB_WEBHOOK_RETRIES', 3), +'retry_delay' => (int) env('REVERB_WEBHOOK_RETRY_DELAY', 1), ``` If a webhook delivery exhausts all retry attempts, Reverb dispatches the `Hypervel\Reverb\Webhooks\Events\WebhookFailed` event. diff --git a/src/docs/routing.md b/src/docs/routing.md index 60083b8b4..7e617b0a2 100644 --- a/src/docs/routing.md +++ b/src/docs/routing.md @@ -1149,7 +1149,7 @@ If you only need to override a few options, you may merge your changes with the ```php HandleCors::resolveConfigUsing(function (Request $request) { - return array_merge(config('cors'), [ + return array_merge(config()->array('cors'), [ 'allowed_origins' => ['https://' . $request->getHost()], ]); }); diff --git a/src/docs/saloon.md b/src/docs/saloon.md index f3c9da748..7f62f9ee4 100644 --- a/src/docs/saloon.md +++ b/src/docs/saloon.md @@ -192,7 +192,7 @@ You may bind the configured connector in a service provider: use App\Http\Integrations\GitHub\GitHubConnector; $this->app->singleton(GitHubConnector::class, function () { - return new GitHubConnector(config('services.github.token')); + return new GitHubConnector(config()->string('services.github.token')); }); ``` @@ -836,7 +836,7 @@ use Hypervel\Saloon\Http\PendingRequest; public function boot(): void { - $applicationName = (string) config('app.name'); + $applicationName = config()->string('app.name'); Saloon::middleware()->onRequest( function (PendingRequest $request) use ($applicationName): void { @@ -1132,8 +1132,8 @@ class GitHubConnector extends Connector protected function defaultOAuthConfig(): OAuthConfig { return new OAuthConfig( - clientId: config('services.github.client_id'), - clientSecret: config('services.github.client_secret'), + clientId: config()->string('services.github.client_id'), + clientSecret: config()->string('services.github.client_secret'), redirectUri: route('github.callback'), authorizeEndpoint: 'https://github.com/login/oauth/authorize', tokenEndpoint: 'https://github.com/login/oauth/access_token', diff --git a/src/docs/sanctum.md b/src/docs/sanctum.md index 560ace0ea..42967fc49 100644 --- a/src/docs/sanctum.md +++ b/src/docs/sanctum.md @@ -175,18 +175,20 @@ Token caching is disabled by default. You may enable and configure it in your ap ```php 'cache' => [ - 'enabled' => env('SANCTUM_CACHE_ENABLED', false), + 'enabled' => (bool) env('SANCTUM_CACHE_ENABLED', false), 'store' => env('SANCTUM_CACHE_STORE'), 'ttl' => (int) env('SANCTUM_CACHE_TTL', 300), 'prefix' => env('SANCTUM_CACHE_PREFIX', 'sanctum'), 'last_used_at_update_interval' => filter_var( - env('SANCTUM_LAST_USED_UPDATE_INTERVAL', 300), + env('SANCTUM_LAST_USED_AT_UPDATE_INTERVAL', 300), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE, ), ], ``` +The cache record and each of its members are optional. Omitting the record disables caching. When caching is enabled, omitted members use the default cache store, a 300-second TTL, the `sanctum` prefix, and a 300-second last-used update interval. + When caching is enabled, Sanctum adds the selected personal access token model, Eloquent models used by configured Sanctum guard providers, and Hypervel's standard Eloquent collection and pivot classes to the cache class policy automatically. Declare custom-provider morph targets, nested `$with` relations, custom collections or pivots, and other application-owned objects from a service provider: ```php @@ -514,7 +516,7 @@ public function boot(): void EnsureFrontendRequestsAreStateful::resolveStatefulDomainsUsing(function (Request $request): array { $tenant = app(TenantResolver::class)->forRequest($request); - return $tenant ? [$tenant->domain] : config('sanctum.stateful_domains', []); + return $tenant ? [$tenant->domain] : config()->array('sanctum.stateful_domains'); }); } ``` @@ -551,6 +553,8 @@ use Hypervel\Foundation\Configuration\Middleware; }) ``` +The entries in `sanctum.middleware` are optional. Omitting cookie encryption or CSRF validation uses Sanctum's default middleware, while session authentication is omitted by default. Set any entry to `null` to remove that middleware from the stateful request pipeline. + #### CORS and Cookies diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md index b957c2d51..16f8dbd6f 100644 --- a/src/docs/scheduling.md +++ b/src/docs/scheduling.md @@ -331,6 +331,8 @@ If you are repeatedly assigning the same timezone to all of your scheduled tasks 'schedule_timezone' => 'America/Chicago', ``` +When this option is omitted, scheduled tasks use the application timezone. + > [!WARNING] > Remember that some timezones utilize daylight savings time. When daylight saving time changes occur, your scheduled task may run twice or even not run at all. For this reason, we recommend avoiding timezone scheduling when possible. @@ -357,6 +359,12 @@ Schedule::command('emails:send')->withoutOverlapping(10); Behind the scenes, the `withoutOverlapping` method utilizes your application's [cache](/docs/{{version}}/cache) to obtain locks. If necessary, you can clear these cache locks using the `schedule:clear-cache` Artisan command. This is typically only necessary if a task becomes stuck due to an unexpected server problem. +By default, scheduling locks use the cache store configured by the `cache.schedule_store` option. This option reads the `SCHEDULE_CACHE_STORE` environment variable and may be set to null to use your application's default cache store: + +```ini +SCHEDULE_CACHE_STORE=database +``` + ### Running Tasks on One Server @@ -376,7 +384,7 @@ Schedule::command('report:generate') ->onOneServer(); ``` -You may use the `useCache` method to customize the cache store used by the scheduler to obtain the atomic locks necessary for single-server tasks: +You may use the `useCache` method to override the configured cache store used by the scheduler to obtain the atomic locks necessary for single-server tasks: ```php Schedule::useCache('database'); diff --git a/src/docs/scout.md b/src/docs/scout.md index 142f05d45..721b77627 100644 --- a/src/docs/scout.md +++ b/src/docs/scout.md @@ -94,7 +94,7 @@ Of course, if you customize the connection and queue that Scout jobs utilize, yo php artisan queue:work redis --queue=scout ``` -Each queue option may also be set via the `SCOUT_QUEUE`, `SCOUT_QUEUE_CONNECTION`, and `SCOUT_QUEUE_NAME` environment variables. +Each queue option may also be set via the `SCOUT_QUEUE`, `SCOUT_QUEUE_CONNECTION`, and `SCOUT_QUEUE_NAME` environment variables. If the nested `enabled` option is omitted, Scout keeps its default deferred, non-queued indexing mode. #### Transaction-Safe Dispatch @@ -169,6 +169,8 @@ MEILISEARCH_HOST=http://127.0.0.1:7700 MEILISEARCH_KEY=masterKey ``` +Omitting the Meilisearch host uses `http://localhost:7700`. Retry settings may also be omitted; Scout then retries transient failures three times with an initial 100-millisecond delay. + For more information regarding Meilisearch, please consult the [Meilisearch documentation](https://docs.meilisearch.com/learn/getting_started/quick_start.html). In addition, you should ensure that you install a version of `meilisearch/meilisearch-php` that is compatible with your Meilisearch binary version by reviewing [Meilisearch's documentation regarding binary compatibility](https://github.com/meilisearch/meilisearch-php#-compatibility-with-meilisearch). @@ -402,6 +404,8 @@ To use the null engine, set the `driver` value to `null` in your `config/scout.p 'driver' => 'null', ``` +Scout also selects the null engine when the resolved `scout.driver` setting is absent or set to PHP `null`. Omitting the `SCOUT_DRIVER` environment variable does not do this because the shipped configuration defaults to the `collection` engine. + ## Third-Party Engine Configuration @@ -571,6 +575,8 @@ use App\Models\Flight; 'meilisearch' => [ 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), 'key' => env('MEILISEARCH_KEY', null), + 'retries' => (int) env('MEILISEARCH_RETRIES', 3), + 'initial_retry_delay_ms' => (int) env('MEILISEARCH_INITIAL_RETRY_DELAY_MS', 100), 'index-settings' => [ User::class => [ 'filterableAttributes'=> ['id', 'name', 'email'], @@ -745,6 +751,8 @@ You may optionally control the chunk size, the ID range, and the destination que php artisan scout:queue-import "App\Models\Post" --chunk=500 --min=1000 --max=50000 --queue=imports ``` +Scout processes searchable and unsearchable records in chunks of 500 when their nested configuration members are omitted. + Imports run in ascending key order by default. To process the range from the highest key to the lowest, pass `--order=desc`: ```shell diff --git a/src/docs/sentry.md b/src/docs/sentry.md index 7e52aa9e6..4e144f619 100644 --- a/src/docs/sentry.md +++ b/src/docs/sentry.md @@ -3,6 +3,7 @@ - [Introduction](#introduction) - [Installation](#installation) - [Configuration](#configuration) + - [Replacing the Service Provider](#replacing-the-service-provider) - [Testing Your Installation](#testing-your-installation) - [Reporting Exceptions](#reporting-exceptions) - [Logging](#logging) @@ -65,6 +66,24 @@ SENTRY_HYPERVEL_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 You may leave the DSN unset to disable event delivery while keeping the package installed, unless Spotlight is enabled. +The `breadcrumbs` and `tracing` groups may be replaced with partial arrays. Omitted built-in members retain the defaults shown in the published configuration file, while application-defined SDK options remain unchanged. + + +### Replacing the Service Provider + +You may extend `SentryServiceProvider` when your application needs to bind Sentry under a different container and configuration key: + +```php +use Hypervel\Sentry\SentryServiceProvider as BaseSentryServiceProvider; + +class SentryServiceProvider extends BaseSentryServiceProvider +{ + public static string $abstract = 'custom-sentry'; +} +``` + +Add `hypervel/sentry` to `extra.hypervel.dont-discover` in your application's `composer.json`, then register the custom provider in `bootstrap/providers.php`. The custom provider replaces the discovered provider; registering both is not supported because they would share one Sentry SDK hub while reading different configuration roots. + ### Testing Your Installation @@ -139,7 +158,7 @@ Log::channel('sentry_logs')->info('Order shipped', [ ]); ``` -The channel uses `SENTRY_LOG_LEVEL`, falling back to `SENTRY_LOGS_LEVEL` and then your application's `LOG_LEVEL` value. +The channel uses `SENTRY_LOG_LEVEL` and falls back directly to your application's `LOG_LEVEL` value. The upstream `SENTRY_LOGS_LEVEL` compatibility alias is not supported. ## Performance Monitoring @@ -152,6 +171,8 @@ SENTRY_TRACES_SAMPLE_RATE=0.1 The default configuration traces requests, database queries, HTTP client requests, cache operations, queued jobs, notifications, and views. The conventional `/up` health route path is ignored by default. +Enabling cache spans or breadcrumbs also enables repository events for every configured cache store while Sentry has an active DSN or Spotlight endpoint. This applies even when a store's own `events` option is `false`, because those events are required to record cache telemetry. Disable both `SENTRY_TRACE_CACHE_ENABLED` and `SENTRY_BREADCRUMBS_CACHE_ENABLED` when cache repository events must remain disabled. + Incoming trace headers are still propagated when local trace recording is disabled. This allows a Hypervel service to remain part of a distributed trace without recording its own transaction. By default, request transactions include work performed after the response is sent. You may finish transactions during the HTTP terminate phase instead: @@ -275,6 +296,13 @@ To configure a single disk, pass its name and configuration: Both methods accept `enableSpans` and `enableBreadcrumbs` arguments. Per-disk settings cannot enable telemetry that is disabled globally. +Filesystem spans and breadcrumbs are enabled globally by default. You may disable either form of telemetry using `SENTRY_TRACE_STORAGE_ENABLED` or `SENTRY_BREADCRUMBS_STORAGE_ENABLED`: + +```ini +SENTRY_TRACE_STORAGE_ENABLED=false +SENTRY_BREADCRUMBS_STORAGE_ENABLED=false +``` + The integration preserves filesystem pooling, scoped prefixes, temporary URLs, streaming behavior, and fluent filesystem operations. diff --git a/src/docs/socialite.md b/src/docs/socialite.md index a4e7bcd6d..17beb998e 100644 --- a/src/docs/socialite.md +++ b/src/docs/socialite.md @@ -399,7 +399,7 @@ use Hypervel\Socialite\Socialite; Socialite::extend('saml', function (Container $app) { return (new SamlProvider($app->make('request'))) - ->withConfig($app->make('config')->get('services.saml')); + ->withConfig($app->make('config')->array('services.saml')); }); ``` diff --git a/src/docs/telescope.md b/src/docs/telescope.md index 4b5a92030..d3529205c 100644 --- a/src/docs/telescope.md +++ b/src/docs/telescope.md @@ -108,7 +108,7 @@ After publishing Telescope's configuration, its primary configuration file will If desired, you may disable Telescope's data collection entirely using the `enabled` configuration option: ```php -'enabled' => env('TELESCOPE_ENABLED', true), +'enabled' => (bool) env('TELESCOPE_ENABLED', true), ``` @@ -328,7 +328,7 @@ Some watchers also allow you to provide additional customization options: ```php 'watchers' => [ Watchers\QueryWatcher::class => [ - 'enabled' => env('TELESCOPE_QUERY_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_QUERY_WATCHER', true), 'slow' => 100, ], // ... @@ -353,7 +353,7 @@ The command watcher records the arguments, options, exit code, and output whenev ```php 'watchers' => [ Watchers\CommandWatcher::class => [ - 'enabled' => env('TELESCOPE_COMMAND_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_COMMAND_WATCHER', true), 'ignore' => ['key:generate'], ], // ... @@ -383,7 +383,7 @@ The gate watcher records the data and result of [gate and policy](/docs/{{versio ```php 'watchers' => [ Watchers\GateWatcher::class => [ - 'enabled' => env('TELESCOPE_GATE_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_GATE_WATCHER', true), 'ignore_abilities' => ['viewNova'], ], // ... @@ -402,11 +402,11 @@ You may ignore specific hosts or limit the recorded request and response payload ```php 'watchers' => [ Watchers\ClientRequestWatcher::class => [ - 'enabled' => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_CLIENT_REQUEST_WATCHER', true), 'ignore_hosts' => [], - 'request_size_limit' => env('TELESCOPE_HTTP_CLIENT_REQUEST_SIZE_LIMIT', 64), - 'response_size_limit' => env('TELESCOPE_HTTP_CLIENT_RESPONSE_SIZE_LIMIT', 64), - 'truncate_oversized' => env('TELESCOPE_HTTP_CLIENT_TRUNCATE_OVERSIZED', false), + 'request_size_limit' => (int) env('TELESCOPE_HTTP_CLIENT_REQUEST_SIZE_LIMIT', 64), + 'response_size_limit' => (int) env('TELESCOPE_HTTP_CLIENT_RESPONSE_SIZE_LIMIT', 64), + 'truncate_oversized' => (bool) env('TELESCOPE_HTTP_CLIENT_TRUNCATE_OVERSIZED', false), ], // ... @@ -430,7 +430,7 @@ By default, Telescope will only record logs at the `error` level and above. Howe ```php 'watchers' => [ Watchers\LogWatcher::class => [ - 'enabled' => env('TELESCOPE_LOG_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_LOG_WATCHER', true), 'level' => 'debug', ], @@ -451,7 +451,7 @@ The model watcher records model changes whenever an Eloquent [model event](/docs ```php 'watchers' => [ Watchers\ModelWatcher::class => [ - 'enabled' => env('TELESCOPE_MODEL_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_MODEL_WATCHER', true), 'events' => ['eloquent.created*', 'eloquent.updated*'], ], // ... @@ -463,7 +463,7 @@ If you would like to record the number of models hydrated during a given request ```php 'watchers' => [ Watchers\ModelWatcher::class => [ - 'enabled' => env('TELESCOPE_MODEL_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_MODEL_WATCHER', true), 'hydrations' => true, ], // ... @@ -483,7 +483,7 @@ The query watcher records the raw SQL, bindings, and execution time for all quer ```php 'watchers' => [ Watchers\QueryWatcher::class => [ - 'enabled' => env('TELESCOPE_QUERY_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_QUERY_WATCHER', true), 'slow' => 50, ], // ... @@ -503,7 +503,7 @@ The Reverb watcher records [Reverb](/docs/{{version}}/reverb) WebSocket events s ```php 'watchers' => [ Watchers\ReverbWatcher::class => [ - 'enabled' => env('TELESCOPE_REVERB_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_REVERB_WATCHER', true), 'events' => [ 'connection_established', 'connection_closed', @@ -511,7 +511,7 @@ The Reverb watcher records [Reverb](/docs/{{version}}/reverb) WebSocket events s 'channel_removed', 'connection_pruned', ], - 'message_size_limit' => env('TELESCOPE_REVERB_MESSAGE_SIZE_LIMIT', 64), + 'message_size_limit' => (int) env('TELESCOPE_REVERB_MESSAGE_SIZE_LIMIT', 64), ], // ... @@ -530,8 +530,8 @@ The request watcher records the request, headers, session, and response data ass ```php 'watchers' => [ Watchers\RequestWatcher::class => [ - 'enabled' => env('TELESCOPE_REQUEST_WATCHER', true), - 'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64), + 'enabled' => (bool) env('TELESCOPE_REQUEST_WATCHER', true), + 'size_limit' => (int) env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64), 'ignore_http_methods' => [], 'ignore_status_codes' => [], ], diff --git a/src/fortify/config/fortify.php b/src/fortify/config/fortify.php index 8a85c4d49..b6390b105 100644 --- a/src/fortify/config/fortify.php +++ b/src/fortify/config/fortify.php @@ -4,6 +4,11 @@ use Hypervel\Fortify\Features; +/** @var null|string $appUrl */ +$appUrl = config('app.url'); +$defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); +$defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; + return [ 'middleware' => ['web'], 'guard' => null, @@ -15,10 +20,22 @@ 'prefix' => '', 'domain' => null, 'lowercase_usernames' => false, + + /* + |-------------------------------------------------------------------------- + | Rate Limiters + |-------------------------------------------------------------------------- + | + | Email verification is always rate limited. Omitting its limiter uses + | six attempts per minute; the other endpoint limiters may be null. + | + */ + 'limiters' => [ 'login' => null, 'two-factor' => '5,1', 'passkeys' => null, + 'verification' => '6,1', ], 'paths' => [ 'login' => null, @@ -70,11 +87,22 @@ 'email-verification' => null, 'password-reset' => null, ], + /* + |-------------------------------------------------------------------------- + | Passkeys + |-------------------------------------------------------------------------- + | + | These settings connect Fortify to Hypervel's passkey support. A null + | relying party ID or user handle secret, and an empty origins list, are + | rejected when a WebAuthn operation first needs the corresponding value. + | + */ + 'passkeys' => [ - 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', parse_url(config('app.url'), PHP_URL_HOST)), - 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', [config('app.url')]), + 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', $defaultRelyingPartyId), + 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', $defaultAllowedOrigins), 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), - 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60000), + 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60_000), ], 'features' => [ Features::registration(), diff --git a/src/fortify/routes/routes.php b/src/fortify/routes/routes.php index 637ecca74..af8704bc4 100644 --- a/src/fortify/routes/routes.php +++ b/src/fortify/routes/routes.php @@ -26,16 +26,16 @@ use Hypervel\Passkeys\Http\Controllers\PasskeyRegistrationController; use Hypervel\Support\Facades\Route; -$middleware = (array) config('fortify.middleware'); -$guard = config('fortify.guard'); +$middleware = config()->array('fortify.middleware'); +$guard = config()->get('fortify.guard'); if (is_string($guard) && $guard !== '') { $middleware[] = 'auth.guard:' . $guard; } Route::group(['middleware' => $middleware], function () { - $enableViews = config('fortify.views'); - $authMiddleware = config('fortify.auth_middleware'); + $enableViews = config()->boolean('fortify.views'); + $authMiddleware = config()->string('fortify.auth_middleware'); // Authentication... if ($enableViews) { @@ -44,10 +44,10 @@ ->name('login'); } - $limiter = config('fortify.limiters.login'); - $twoFactorLimiter = config('fortify.limiters.two-factor'); - $passkeyLimiter = config('fortify.limiters.passkeys'); - $verificationLimiter = config('fortify.limiters.verification', '6,1'); + $limiter = config()->get('fortify.limiters.login'); + $twoFactorLimiter = config()->get('fortify.limiters.two-factor'); + $passkeyLimiter = config()->get('fortify.limiters.passkeys'); + $verificationLimiter = config()->string('fortify.limiters.verification', '6,1'); Route::post(RoutePath::for('login', '/login'), [AuthenticatedSessionController::class, 'store']) ->middleware(array_filter([ diff --git a/src/fortify/src/Fortify.php b/src/fortify/src/Fortify.php index b9c5d7654..3574d3398 100644 --- a/src/fortify/src/Fortify.php +++ b/src/fortify/src/Fortify.php @@ -89,7 +89,7 @@ public static function redirects(string $redirect, mixed $default = null, ?Reque return (string) (self::config()->get("fortify.redirects.{$redirect}") ?? $default - ?? self::config()->get('fortify.home')); + ?? self::config()->string('fortify.home')); } /** diff --git a/src/fortify/src/FortifyServiceProvider.php b/src/fortify/src/FortifyServiceProvider.php index 882538ea9..889d903e9 100644 --- a/src/fortify/src/FortifyServiceProvider.php +++ b/src/fortify/src/FortifyServiceProvider.php @@ -120,13 +120,16 @@ protected function configurePasskeys(): void $this->app->make(ConfigMutationTracker::class)->applyAndRecord( $config, static function (ConfigRepository $config): void { - $appUrl = $config->string('app.url'); + /** @var null|string $appUrl */ + $appUrl = $config->get('app.url'); + $defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); + $defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; $config->set([ - 'passkeys.relying_party_id' => $config->string('fortify.passkeys.relying_party_id', parse_url($appUrl, PHP_URL_HOST)), - 'passkeys.allowed_origins' => $config->array('fortify.passkeys.allowed_origins', [$appUrl]), - 'passkeys.user_handle_secret' => $config->string('fortify.passkeys.user_handle_secret', $config->string('app.key')), - 'passkeys.timeout' => $config->integer('fortify.passkeys.timeout', 60000), + 'passkeys.relying_party_id' => $config->get('fortify.passkeys.relying_party_id', $defaultRelyingPartyId), + 'passkeys.allowed_origins' => $config->get('fortify.passkeys.allowed_origins', $defaultAllowedOrigins), + 'passkeys.user_handle_secret' => $config->get('fortify.passkeys.user_handle_secret', $config->get('app.key')), + 'passkeys.timeout' => $config->integer('fortify.passkeys.timeout', Passkeys::DEFAULT_TIMEOUT), ]); }, ); diff --git a/src/fortify/stubs/fortify.php b/src/fortify/stubs/fortify.php index f7a4551c8..923044805 100644 --- a/src/fortify/stubs/fortify.php +++ b/src/fortify/stubs/fortify.php @@ -4,6 +4,11 @@ use Hypervel\Fortify\Features; +/** @var null|string $appUrl */ +$appUrl = config('app.url'); +$defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); +$defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; + return [ /* |-------------------------------------------------------------------------- @@ -133,6 +138,8 @@ |-------------------------------------------------------------------------- | | These values select the rate limiters used by Fortify's public endpoints. + | Email verification is always rate limited. Omitting its limiter uses + | six attempts per minute; the other endpoint limiters may be null. | */ @@ -140,6 +147,7 @@ 'login' => 'login', 'two-factor' => '5,1', 'passkeys' => 'passkeys', + 'verification' => '6,1', ], /* @@ -147,15 +155,17 @@ | Passkeys |-------------------------------------------------------------------------- | - | These settings connect Fortify to Hypervel's passkey support. + | These settings connect Fortify to Hypervel's passkey support. A null + | relying party ID or user handle secret, and an empty origins list, are + | rejected when a WebAuthn operation first needs the corresponding value. | */ 'passkeys' => [ - 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', parse_url(config('app.url'), PHP_URL_HOST)), - 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', [config('app.url')]), + 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', $defaultRelyingPartyId), + 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', $defaultAllowedOrigins), 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), - 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60000), + 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60_000), ], /* diff --git a/src/foundation/config/app.php b/src/foundation/config/app.php index c985383d7..d19a9ed2c 100644 --- a/src/foundation/config/app.php +++ b/src/foundation/config/app.php @@ -63,6 +63,19 @@ 'debug' => (bool) env('APP_DEBUG', false), + /* + |-------------------------------------------------------------------------- + | Source Editor + |-------------------------------------------------------------------------- + | + | This setting controls clickable source links on development exception + | pages. Set it to an editor name or an array of editor options. Omit this + | option or set it to null to disable source links. + | + */ + + // 'editor' => 'vscode', + /* |-------------------------------------------------------------------------- | Stdout Log Configuration @@ -122,9 +135,15 @@ | Application URL |-------------------------------------------------------------------------- | - | This URL is used by the console to properly generate URLs when using - | the Artisan command line tool. You should set this to the root of - | the application so that it's available within Artisan commands. + | This URL is used whenever Hypervel needs to know the root of your + | application: generating URLs in console commands, queued jobs, and + | mail, and deriving trusted host patterns for incoming requests. You + | should set this to the root of your application. + | + | Set this to null only when the application has no canonical URL. + | Features that support a missing URL use their documented behavior, + | while features that require an absolute URL fail until a URL is + | configured. | */ @@ -143,12 +162,16 @@ | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. The timezone - | is set to "UTC" by default as it is suitable for most use cases. + | is set to "UTC" by default as it is suitable for most use cases. You may + | specify a separate default timezone for scheduled tasks. When omitted, + | scheduled tasks use the application timezone. | */ 'timezone' => env('APP_TIMEZONE', 'UTC'), + // 'schedule_timezone' => 'America/Chicago', + /* |-------------------------------------------------------------------------- | Application Locale Configuration diff --git a/src/foundation/config/auth.php b/src/foundation/config/auth.php index fd211d5c0..224f3d411 100644 --- a/src/foundation/config/auth.php +++ b/src/foundation/config/auth.php @@ -31,12 +31,22 @@ | users are actually retrieved out of your database or other storage | system used by the application. Typically, Eloquent is utilized. | - | Guards that send password reset links declare their broker with - | the "passwords" key, referencing an entry in the passwords array. + | Guards that send password reset links declare their broker with the + | "passwords" key, referencing an entry in the passwords array. Guards + | that do not select a default password broker may omit this key. | Sanctum guards declare the session guards they trust for first-party | SPA requests with the "session_guards" key; set it to an empty array - | for bearer-token-only APIs. Guards may also override the password - | confirmation window with a "password_timeout" key. + | for bearer-token-only APIs. Guards may set "password_timeout" to a + | lifetime in seconds; omission or null inherits the application-wide + | password confirmation window. Session guards may set "remember" to a + | lifetime in minutes; omission or null keeps the built-in lifetime. JWT + | guards may set "ttl" to an integer number of minutes or null for + | non-expiring tokens; omission inherits the global jwt.ttl value. + | + | Token guards require "provider". The optional "input_key", "storage_key", + | and "hash" members retain TokenGuard's public factory defaults when + | omitted. Request and custom guards may use a null provider when their + | implementation does not retrieve users through a provider. | | Supported by default: "session". Install hypervel/sanctum to use | the "sanctum" guard, and hypervel/jwt to use the "jwt" guard @@ -48,6 +58,8 @@ 'driver' => 'session', 'provider' => 'users', 'passwords' => 'users', + // 'password_timeout' => 60 * 60, + // 'remember' => 60 * 24 * 30, ], 'sanctum' => [ 'driver' => 'sanctum', @@ -75,6 +87,9 @@ | | Supported: "database", "eloquent" | + | Database providers require a "table". An optional "connection" selects + | the database connection; omission or null uses the default connection. + | */ 'providers' => [ @@ -89,7 +104,8 @@ | | Caches retrieveById() lookups across requests. Disabled by | default. Credential and token lookups are never cached - | (security). + | (security). The record may be omitted; omitted members use the + | defaults shown below. | | Supported stores: 'redis', 'database', 'file', 'storage', | 'swoole', and stacks containing only supported stores. Array, @@ -120,13 +136,19 @@ | */ 'cache' => [ - 'enabled' => env('AUTH_USERS_CACHE_ENABLED', false), - 'store' => env('AUTH_USERS_CACHE_STORE'), - 'ttl' => (int) env('AUTH_USERS_CACHE_TTL', 300), - 'prefix' => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'), + 'enabled' => (bool) env('AUTH_USER_CACHE_ENABLED', false), + 'store' => env('AUTH_USER_CACHE_STORE'), + 'ttl' => (int) env('AUTH_USER_CACHE_TTL', 300), + 'prefix' => env('AUTH_USER_CACHE_PREFIX', 'auth_user'), 'tags' => null, ], ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // 'connection' => null, + // ], ], /* @@ -161,10 +183,21 @@ | generating more password reset tokens. This prevents the user from | quickly generating a very large amount of password reset tokens. | + | Database brokers require "driver", "provider", and "table". An optional + | "connection" selects the database connection used for reset tokens; + | omission or null uses the default connection. Cache brokers replace + | "table" with an optional "store" member; omission or null uses the + | default cache store. + | + | The optional "expire" and "throttle" members default to 60 minutes and + | zero seconds. This example explicitly limits token generation to once + | per minute. + | */ 'passwords' => [ 'users' => [ + 'driver' => 'database', 'provider' => 'users', 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), 'expire' => 60, diff --git a/src/foundation/config/broadcasting.php b/src/foundation/config/broadcasting.php index a8890f288..5914c2e9b 100644 --- a/src/foundation/config/broadcasting.php +++ b/src/foundation/config/broadcasting.php @@ -37,7 +37,7 @@ 'app_id' => env('REVERB_APP_ID'), 'options' => [ 'host' => env('REVERB_HOST'), - 'port' => env('REVERB_PORT', 443), + 'port' => (int) env('REVERB_PORT', 443), 'scheme' => env('REVERB_SCHEME', 'https'), 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', 'path' => env('REVERB_SERVER_PATH', ''), @@ -45,6 +45,7 @@ 'client_options' => [ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html ], + // 'log' => true, 'jsonp' => false, ], @@ -56,7 +57,7 @@ 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'host' => env('PUSHER_HOST') ?: 'api-' . env('PUSHER_APP_CLUSTER', 'mt1') . '.pusher.com', - 'port' => env('PUSHER_PORT', 443), + 'port' => (int) env('PUSHER_PORT', 443), 'scheme' => env('PUSHER_SCHEME', 'https'), 'encrypted' => true, 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', @@ -64,6 +65,7 @@ 'client_options' => [ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html ], + // 'log' => true, 'jsonp' => false, ], diff --git a/src/foundation/config/cache.php b/src/foundation/config/cache.php index 6404a33ed..32dae1eeb 100644 --- a/src/foundation/config/cache.php +++ b/src/foundation/config/cache.php @@ -18,6 +18,18 @@ 'default' => env('CACHE_STORE', 'database'), + /* + |-------------------------------------------------------------------------- + | Schedule Cache Store + |-------------------------------------------------------------------------- + | + | This store coordinates scheduled tasks across processes and servers. + | Set it to null to use the default cache store. + | + */ + + 'schedule_store' => env('SCHEDULE_CACHE_STORE'), + /* |-------------------------------------------------------------------------- | Cache Stores @@ -31,6 +43,16 @@ | "storage", "redis", "swoole", "stack", "session", | "failover", "null" | + | Database, storage, and Redis stores may define a store-specific prefix; + | omission or null inherits the global cache prefix. Nullable connection, + | lock connection, and disk values select their manager's default. + | + | File stores use the operating system's permissions unless "permission" + | sets one mode for cache files and generated directories. Cache repository + | events are enabled by default; setting "events" to false disables them + | for a store. The failover repository disables its outer events because + | its backing stores dispatch them. + | */ 'stores' => [ diff --git a/src/foundation/config/database.php b/src/foundation/config/database.php index be71b8ffa..ccca51844 100644 --- a/src/foundation/config/database.php +++ b/src/foundation/config/database.php @@ -36,6 +36,9 @@ | Below are all of the database connections defined for your application. | An example configuration is provided for each database system which | is supported by Hypervel. You're free to add / remove connections. + | A connection may set "migrations_connection" to route migrations + | through another named connection. When omitted, migrations use the + | selected connection itself. | */ @@ -46,7 +49,7 @@ 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'prefix_indexes' => null, - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'foreign_key_constraints' => (bool) env('DB_FOREIGN_KEYS', true), 'busy_timeout' => null, 'journal_mode' => null, 'synchronous' => null, @@ -58,7 +61,7 @@ 'driver' => 'mysql', 'url' => env('DB_URL'), 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', 3306), + 'port' => (int) env('DB_PORT', 3306), 'database' => env('DB_DATABASE', 'hypervel'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), @@ -88,7 +91,7 @@ 'driver' => 'mariadb', 'url' => env('DB_URL'), 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', 3306), + 'port' => (int) env('DB_PORT', 3306), 'database' => env('DB_DATABASE', 'hypervel'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), @@ -118,7 +121,7 @@ 'driver' => 'pgsql', 'url' => env('DB_URL'), 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', 5432), + 'port' => (int) env('DB_PORT', 5432), 'database' => env('DB_DATABASE', 'hypervel'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), @@ -146,7 +149,7 @@ 'driver' => 'pgsql', 'url' => env('DB_POOLED_URL', env('DB_URL')), 'host' => env('DB_POOLED_HOST', env('DB_HOST', 'localhost')), - 'port' => env('DB_POOLED_PORT', 6432), + 'port' => (int) env('DB_POOLED_PORT', 6432), 'database' => env('DB_POOLED_DATABASE', env('DB_DATABASE', 'hypervel')), 'username' => env('DB_POOLED_USERNAME', env('DB_USERNAME', 'root')), 'password' => env('DB_POOLED_PASSWORD', env('DB_PASSWORD', '')), @@ -180,6 +183,8 @@ | This table keeps track of all the migrations that have already run for | your application. Using this information, we can determine which of | the migrations on disk haven't actually been run on the database. + | Omitting update_date_on_publish leaves published migration timestamps + | unchanged. | */ @@ -195,7 +200,10 @@ | | Redis is an open source, fast, and advanced key-value store that also | provides a richer body of commands than a typical key-value system - | such as Memcached. You may define your connection settings here. + | such as Memcached. Advanced transport, connection and read timeouts, + | retry and backoff settings, client name, stream context, per-connection + | options, prefix, and command event settings may be added when needed. + | The Redis documentation describes these optional settings. | */ diff --git a/src/foundation/config/filesystems.php b/src/foundation/config/filesystems.php index 35f17f09b..37fc24236 100644 --- a/src/foundation/config/filesystems.php +++ b/src/foundation/config/filesystems.php @@ -27,13 +27,19 @@ | | Supported drivers: "local", "ftp", "sftp", "s3", "gcs" | + | The built-in disks declare their default visibility and whether storage + | failures should be thrown or reported. S3-compatible services may also + | require a custom endpoint, path-style URLs, or provider-specific region. + | */ 'disks' => [ 'local' => [ 'driver' => 'local', 'root' => storage_path('app/private'), + 'visibility' => 'private', 'throw' => false, + 'report' => false, ], 'public' => [ @@ -42,18 +48,24 @@ 'url' => env('APP_URL') . '/storage', 'visibility' => 'public', 'throw' => false, + 'report' => false, ], 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), + // Uncomment when using temporary AWS credentials. + // 'token' => env('AWS_SESSION_TOKEN'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), + 'root' => env('AWS_ROOT', ''), 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), - 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'use_path_style_endpoint' => (bool) env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'visibility' => 'public', 'throw' => false, + 'report' => false, 'stream_reads' => true, 'pool' => [ 'min_retained_objects' => 1, @@ -78,6 +90,7 @@ 'visibility_handler' => null, // optional: set to \League\Flysystem\GoogleCloudStorage\UniformBucketLevelAccessVisibility::class to enable uniform bucket level access 'metadata' => ['cacheControl' => 'public,max-age=86400'], // optional: default metadata 'throw' => false, + 'report' => false, 'stream_reads' => true, 'pool' => [ 'min_retained_objects' => 1, diff --git a/src/foundation/config/hashing.php b/src/foundation/config/hashing.php index 4f786e17e..d1912a11e 100644 --- a/src/foundation/config/hashing.php +++ b/src/foundation/config/hashing.php @@ -2,6 +2,8 @@ declare(strict_types=1); +$bcryptLimit = env('BCRYPT_LIMIT'); + return [ /* |-------------------------------------------------------------------------- @@ -30,9 +32,9 @@ */ 'bcrypt' => [ - 'rounds' => env('BCRYPT_ROUNDS', 12), - 'verify' => env('HASH_VERIFY', true), - 'limit' => env('BCRYPT_LIMIT', null), + 'rounds' => (int) env('BCRYPT_ROUNDS', 12), + 'verify' => (bool) env('HASH_VERIFY', true), + 'limit' => $bcryptLimit === null ? null : (int) $bcryptLimit, ], /* @@ -47,10 +49,10 @@ */ 'argon' => [ - 'memory' => env('ARGON_MEMORY', 65536), - 'threads' => env('ARGON_THREADS', 1), - 'time' => env('ARGON_TIME', 4), - 'verify' => env('HASH_VERIFY', true), + 'memory' => (int) env('ARGON_MEMORY', 65536), + 'threads' => (int) env('ARGON_THREADS', 1), + 'time' => (int) env('ARGON_TIME', 4), + 'verify' => (bool) env('HASH_VERIFY', true), ], /* diff --git a/src/foundation/config/logging.php b/src/foundation/config/logging.php index cb84b6fa8..5d125c761 100644 --- a/src/foundation/config/logging.php +++ b/src/foundation/config/logging.php @@ -7,6 +7,9 @@ use Monolog\Handler\SyslogUdpHandler; use Monolog\Processor\PsrLogMessageProcessor; +$papertrailPort = env('PAPERTRAIL_PORT'); +$papertrailPort = $papertrailPort === null ? null : (int) $papertrailPort; + return [ /* |-------------------------------------------------------------------------- @@ -29,12 +32,15 @@ | This option controls the log channel that should be used to log warnings | regarding deprecated PHP and library features. This allows you to get | your application ready for upcoming major versions of dependencies. + | Omitting the channel or setting it to null uses the null logger. An + | omitted trace setting disables stack traces; Testbench enables them so + | deprecation call sites remain visible during tests. | */ 'deprecations' => [ 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), - 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + 'trace' => (bool) env('LOG_DEPRECATIONS_TRACE', false), ], /* @@ -49,6 +55,11 @@ | Available drivers: "single", "daily", "slack", "syslog", | "errorlog", "monolog", "custom", "stack" | + | Built-in channel records below declare their driver-specific settings. + | Any channel may also set a custom "name", a "tap" list, or optional + | formatter and action-level settings. A null file permission uses the + | operating system's default permissions. + | */ 'channels' => [ @@ -62,6 +73,7 @@ 'driver' => 'single', 'path' => storage_path('logs/hypervel.log'), 'level' => env('LOG_LEVEL', 'debug'), + 'permission' => null, 'replace_placeholders' => true, ], @@ -69,7 +81,8 @@ 'driver' => 'daily', 'path' => storage_path('logs/hypervel.log'), 'level' => env('LOG_LEVEL', 'debug'), - 'days' => env('LOG_DAILY_DAYS', 14), + 'days' => (int) env('LOG_DAILY_DAYS', 14), + 'permission' => null, 'replace_placeholders' => true, ], @@ -78,7 +91,9 @@ 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Hypervel')), 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'context' => true, 'level' => env('LOG_LEVEL', 'critical'), + 'exclude_fields' => [], 'replace_placeholders' => true, ], @@ -88,8 +103,8 @@ 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 'handler_with' => [ 'host' => env('PAPERTRAIL_URL'), - 'port' => env('PAPERTRAIL_PORT'), - 'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'), + 'port' => $papertrailPort, + 'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . $papertrailPort, ], 'processors' => [PsrLogMessageProcessor::class], ], diff --git a/src/foundation/config/mail.php b/src/foundation/config/mail.php index 9752f2dda..dbe921305 100644 --- a/src/foundation/config/mail.php +++ b/src/foundation/config/mail.php @@ -34,6 +34,11 @@ | "postmark", "resend", "cloudflare", "log", | "array", "failover", "roundrobin" | + | A null SMTP scheme is inferred from its port, while a null timeout leaves + | the transport default unchanged. A null log channel uses the default log + | channel. Poolable named transports use the shared pool defaults; set + | "pool" to false to disable pooling or provide an array of pool options. + | */ 'mailers' => [ @@ -42,7 +47,7 @@ 'scheme' => env('MAIL_SCHEME'), 'url' => env('MAIL_URL'), 'host' => env('MAIL_HOST', '127.0.0.1'), - 'port' => env('MAIL_PORT', 2525), + 'port' => (int) env('MAIL_PORT', 2525), 'username' => env('MAIL_USERNAME'), 'password' => env('MAIL_PASSWORD'), 'timeout' => null, diff --git a/src/foundation/config/queue.php b/src/foundation/config/queue.php index 310edbb27..02ff5d9ba 100644 --- a/src/foundation/config/queue.php +++ b/src/foundation/config/queue.php @@ -25,7 +25,7 @@ | by every worker. | */ - 'concurrency_number' => (int) env('QUEUE_CONCURRENCY_NUMBER', 1), + 'concurrency' => (int) env('QUEUE_CONCURRENCY', 1), /* |-------------------------------------------------------------------------- @@ -36,21 +36,36 @@ | used by your application. An example configuration is provided for | each backend supported by Hypervel. You're also free to add more. | - | Drivers: "sync", "background", "deferred", "database", "beanstalkd", "sqs", "redis", "null" + | Drivers: "sync", "background", "deferred", "database", "beanstalkd", "sqs", "redis", "failover", "null" + | + | Omitting a database or Redis connection selects the corresponding + | default connection. Database, Beanstalkd, and Redis retry timeouts + | default to 60 seconds when omitted. Connection records for drivers + | without named queues may omit the "queue" member. + | + | Except for sync and database, a dispatch waits for the most recently + | started applicable transaction and its enclosing stack to commit. + | Database queue inserts remain in the business transaction by default; + | enable after-commit dispatch when the queue does not share every + | connection whose transactional data the job depends on. Failover waits + | for every applicable transaction so failures remain in its fallback chain. | */ 'connections' => [ 'sync' => [ 'driver' => 'sync', + 'after_commit' => false, ], 'background' => [ 'driver' => 'background', + 'after_commit' => true, ], 'deferred' => [ 'driver' => 'deferred', + 'after_commit' => true, ], 'database' => [ @@ -65,10 +80,11 @@ 'beanstalkd' => [ 'driver' => 'beanstalkd', 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'port' => (int) env('BEANSTALKD_QUEUE_PORT', 11300), 'queue' => env('BEANSTALKD_QUEUE', 'default'), 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), 'block_for' => 0, - 'after_commit' => false, + 'after_commit' => true, 'pool' => [ 'min_retained_objects' => 1, 'max_objects' => 10, @@ -83,17 +99,18 @@ 'driver' => 'sqs', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'token' => env('AWS_SESSION_TOKEN'), 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), 'queue' => env('SQS_QUEUE', 'default'), 'suffix' => env('SQS_SUFFIX'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'after_commit' => false, + 'after_commit' => true, 'overflow' => [ - 'enabled' => env('SQS_OVERFLOW_ENABLED', false), + 'enabled' => (bool) env('SQS_OVERFLOW_ENABLED', false), 'store' => env('SQS_OVERFLOW_STORE'), 'always' => false, 'delete_after_processing' => true, - 'flush_on_clear' => env('SQS_OVERFLOW_FLUSH_ON_CLEAR', false), + 'flush_on_clear' => (bool) env('SQS_OVERFLOW_FLUSH_ON_CLEAR', false), ], 'pool' => [ 'min_retained_objects' => 1, @@ -111,7 +128,16 @@ 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), 'block_for' => null, - 'after_commit' => false, + 'after_commit' => true, + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + 'after_commit' => true, ], ], @@ -142,6 +168,11 @@ | | Supported drivers: "database", "database-uuids", "file", "null" | + | Database drivers require "database" and "table". A null "database" uses + | the default database connection. The file driver uses a "path" and + | "limit" instead; omitting them stores up to 100 failures in + | storage/framework/cache/failed-jobs.json. + | */ 'failed' => [ diff --git a/src/foundation/config/rate-limiter.php b/src/foundation/config/rate-limiter.php index a1c8adf30..2b0605483 100644 --- a/src/foundation/config/rate-limiter.php +++ b/src/foundation/config/rate-limiter.php @@ -23,6 +23,8 @@ | | Here you may configure the stores used to hold rate limiter state. Each | store performs its decisions atomically using its native primitives. + | Database stores may omit "connection" or set it to null to use the + | default database connection. | | Supported drivers: "database", "redis", "swoole", "worker-array" | diff --git a/src/foundation/config/session.php b/src/foundation/config/session.php index ad4da05fe..020b054bc 100644 --- a/src/foundation/config/session.php +++ b/src/foundation/config/session.php @@ -2,6 +2,8 @@ declare(strict_types=1); +$secureCookie = env('SESSION_SECURE_COOKIE'); + return [ /* |-------------------------------------------------------------------------- @@ -68,6 +70,7 @@ | When using the "database" or "redis" session drivers, you may specify a | connection that should be used to manage these sessions. This should | correspond to a connection in the matching driver configuration. + | Set it to null to use that driver's default connection. | */ @@ -119,7 +122,10 @@ | | Session blocking prevents concurrent requests for the same session | from executing at the same time. You may configure the cache store - | and time limits used to acquire and maintain the session lock. + | and time limits used to acquire and maintain the session lock. Set the + | block store to null to use the default cache store. The selected store + | must support atomic locks and be shared by every application instance + | that should coordinate. | */ @@ -176,8 +182,8 @@ |-------------------------------------------------------------------------- | | This value determines the domain and subdomains the session cookie is - | available to. By default, the cookie will be available to the root - | domain and all subdomains. Typically, this shouldn't be changed. + | available to. A null value creates a host-only cookie. Set an explicit + | domain when the cookie should be shared with subdomains. | */ @@ -191,10 +197,12 @@ | By setting this option to true, session cookies will only be sent back | to the server if the browser has a HTTPS connection. This will keep | the cookie from being sent to you when it can't be done securely. + | A null value follows the current request scheme, securing the cookie + | for HTTPS responses but not HTTP responses. | */ - 'secure' => env('SESSION_SECURE_COOKIE'), + 'secure' => $secureCookie === null ? null : (bool) $secureCookie, /* |-------------------------------------------------------------------------- @@ -207,7 +215,7 @@ | */ - 'http_only' => env('SESSION_HTTP_ONLY', true), + 'http_only' => (bool) env('SESSION_HTTP_ONLY', true), /* |-------------------------------------------------------------------------- @@ -237,7 +245,7 @@ | */ - 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + 'partitioned' => (bool) env('SESSION_PARTITIONED_COOKIE', false), /* |-------------------------------------------------------------------------- diff --git a/src/foundation/resources/exceptions/renderer/components/layout.blade.php b/src/foundation/resources/exceptions/renderer/components/layout.blade.php index cbab0172c..0bbaa9761 100644 --- a/src/foundation/resources/exceptions/renderer/components/layout.blade.php +++ b/src/foundation/resources/exceptions/renderer/components/layout.blade.php @@ -5,7 +5,7 @@ - {{ config('app.name', 'Hypervel') }} + {{ config('app.name') ?? 'Hypervel' }} {!! Renderer::css() !!} diff --git a/src/foundation/src/Bootstrap/HandleExceptions.php b/src/foundation/src/Bootstrap/HandleExceptions.php index d1185dbe1..281a55122 100644 --- a/src/foundation/src/Bootstrap/HandleExceptions.php +++ b/src/foundation/src/Bootstrap/HandleExceptions.php @@ -86,10 +86,10 @@ public function handleDeprecationError(string $message, string $file, int $line, $this->ensureDeprecationLoggerIsConfigured(); - $options = static::$app->make('config')->get('logging.deprecations') ?? []; + $trace = static::$app->make('config')->boolean('logging.deprecations.trace', false); - with($logger->channel('deprecations'), function ($log) use ($message, $file, $line, $level, $options) { - if ($options['trace'] ?? false) { + with($logger->channel('deprecations'), function ($log) use ($message, $file, $line, $level, $trace) { + if ($trace) { $log->warning((string) new ErrorException($message, 0, $level, $file, $line)); } else { $log->warning(sprintf( @@ -124,15 +124,14 @@ protected function ensureDeprecationLoggerIsConfigured(): void return; } + $options = $config->array('logging.deprecations'); + $this->ensureNullLogDriverIsConfigured(); - if (is_array($options = $config->get('logging.deprecations'))) { - $driver = $options['channel'] ?? 'null'; - } else { - $driver = $options ?? 'null'; - } + // A declared null channel deliberately selects the null logger. + $driver = $options['channel'] ?? 'null'; - $config->set('logging.channels.deprecations', $config->get("logging.channels.{$driver}")); + $config->set('logging.channels.deprecations', $config->array("logging.channels.{$driver}")); } /** diff --git a/src/foundation/src/Bootstrap/LoadConfiguration.php b/src/foundation/src/Bootstrap/LoadConfiguration.php index 5c9a874ce..60597b940 100644 --- a/src/foundation/src/Bootstrap/LoadConfiguration.php +++ b/src/foundation/src/Bootstrap/LoadConfiguration.php @@ -72,11 +72,11 @@ public function bootstrap(Application $app): void // Finally, we will set the application's environment based on the configuration // values that were loaded. We will pass a callback which will be used to get // the environment in a web context where an "--env" switch is not present. - $app->detectEnvironment(fn () => $config->string('app.env', 'production')); + $app->detectEnvironment(fn () => $config->string('app.env')); $app->resolveEnvironmentUsing($app->environment(...)); - date_default_timezone_set($config->get('app.timezone', 'UTC')); + date_default_timezone_set($config->string('app.timezone')); mb_internal_encoding('UTF-8'); } catch (Throwable $exception) { @@ -221,7 +221,7 @@ protected function getBaseConfiguration(): array $config = []; foreach (Finder::create()->files()->name('*.php')->in(__DIR__ . '/../../config') as $file) { - $config[basename($file->getRealPath(), '.php')] = require $file->getRealPath(); + $config[basename($file->getRealPath(), '.php')] = (fn () => require $file->getRealPath())(); } return $config; diff --git a/src/foundation/src/Console/AboutCommand.php b/src/foundation/src/Console/AboutCommand.php index 523d0a845..4eec398fb 100644 --- a/src/foundation/src/Console/AboutCommand.php +++ b/src/foundation/src/Console/AboutCommand.php @@ -133,17 +133,17 @@ protected function gatherApplicationInformation(): void $formatStorageLinkedStatus = fn ($value) => $value ? 'LINKED' : 'NOT LINKED'; static::addToSection('Environment', fn () => [ - 'Application Name' => config('app.name'), + 'Application Name' => config()->string('app.name'), 'Hypervel Version' => $this->hypervel->version(), 'PHP Version' => phpversion(), 'Swoole Version' => swoole_version(), 'Composer Version' => $this->composer->getVersion() ?? '-', 'Environment' => $this->hypervel->environment(), - 'Debug Mode' => static::format(config('app.debug'), console: $formatEnabledStatus), + 'Debug Mode' => static::format(config()->boolean('app.debug'), console: $formatEnabledStatus), 'URL' => Str::of(config('app.url'))->replace(['http://', 'https://'], ''), 'Maintenance Mode' => static::format($this->hypervel->isDownForMaintenance(), console: $formatEnabledStatus), - 'Timezone' => config('app.timezone'), - 'Locale' => config('app.locale'), + 'Timezone' => config()->string('app.timezone'), + 'Locale' => config()->string('app.locale'), ]); static::addToSection('Cache', fn () => [ @@ -151,16 +151,16 @@ protected function gatherApplicationInformation(): void 'Events' => static::format($this->hypervel->eventsAreCached(), console: $formatCachedStatus), 'Routes' => static::format($this->hypervel->routesAreCached(), console: $formatCachedStatus), 'AOP Proxies' => static::format($this->hasPhpFiles($this->hypervel->storagePath('framework/aop'), 'cache'), console: $formatCachedStatus), - 'Views' => static::format($this->hasPhpFiles(config('view.compiled')), console: $formatCachedStatus), + 'Views' => static::format($this->hasPhpFiles(config()->string('view.compiled')), console: $formatCachedStatus), ]); static::addToSection('Drivers', fn () => array_filter([ - 'Broadcasting' => config('broadcasting.default'), + 'Broadcasting' => config()->string('broadcasting.default'), 'Cache' => function ($json) { - $cacheStore = config('cache.default'); + $cacheStore = config()->string('cache.default'); - if (config('cache.stores.' . $cacheStore . '.driver') === 'failover') { - $secondary = new Collection(config('cache.stores.' . $cacheStore . '.stores')); + if (config()->string('cache.stores.' . $cacheStore . '.driver') === 'failover') { + $secondary = config()->collection('cache.stores.' . $cacheStore . '.stores'); return value(static::format( value: $cacheStore, @@ -171,12 +171,12 @@ protected function gatherApplicationInformation(): void return $cacheStore; }, - 'Database' => config('database.default'), + 'Database' => config()->string('database.default'), 'Logs' => function ($json) { $logChannel = config('logging.default'); - if (config('logging.channels.' . $logChannel . '.driver') === 'stack') { - $secondary = new Collection(config('logging.channels.' . $logChannel . '.channels')); + if (is_string($logChannel) && config()->string('logging.channels.' . $logChannel . '.driver') === 'stack') { + $secondary = config()->collection('logging.channels.' . $logChannel . '.channels'); return value(static::format( value: $logChannel, @@ -188,10 +188,10 @@ protected function gatherApplicationInformation(): void return $logChannel; }, 'Mail' => function ($json) { - $mailMailer = config('mail.default'); + $mailMailer = config()->string('mail.default'); - if (in_array(config('mail.mailers.' . $mailMailer . '.transport'), ['failover', 'roundrobin'])) { - $secondary = new Collection(config('mail.mailers.' . $mailMailer . '.mailers')); + if (in_array(config()->string('mail.mailers.' . $mailMailer . '.transport'), ['failover', 'roundrobin'], true)) { + $secondary = config()->collection('mail.mailers.' . $mailMailer . '.mailers'); return value(static::format( value: $mailMailer, @@ -203,10 +203,10 @@ protected function gatherApplicationInformation(): void return $mailMailer; }, 'Queue' => function ($json) { - $queueConnection = config('queue.default'); + $queueConnection = config()->string('queue.default'); - if (config('queue.connections.' . $queueConnection . '.driver') === 'failover') { - $secondary = new Collection(config('queue.connections.' . $queueConnection . '.connections')); + if (config()->string('queue.connections.' . $queueConnection . '.driver') === 'failover') { + $secondary = config()->collection('queue.connections.' . $queueConnection . '.connections'); return value(static::format( value: $queueConnection, @@ -218,7 +218,7 @@ protected function gatherApplicationInformation(): void return $queueConnection; }, 'Scout' => config('scout.driver'), - 'Session' => config('session.driver'), + 'Session' => config()->string('session.driver'), ])); static::addToSection('Storage', fn () => [ @@ -235,7 +235,7 @@ protected function gatherApplicationInformation(): void */ protected function determineStoragePathLinkStatus(callable $formatStorageLinkedStatus): array { - return (new Collection(config('filesystems.links', []))) + return config()->collection('filesystems.links') ->mapWithKeys(function ($target, $link) use ($formatStorageLinkedStatus) { $path = Str::replace(public_path(), '', $link); diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index 033743dd8..846eec739 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -23,7 +23,6 @@ use Hypervel\Support\Arr; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; -use Hypervel\Support\Env; use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Str; use ReflectionClass; @@ -282,7 +281,7 @@ protected function scheduleTimezone(): ?string { $config = $this->app->make('config'); - return $config->get('app.schedule_timezone', $config->get('app.timezone')); + return $config->get('app.schedule_timezone', $config->string('app.timezone')); } /** @@ -290,9 +289,7 @@ protected function scheduleTimezone(): ?string */ protected function scheduleCache(): ?string { - return $this->app->make('config')->get('cache.schedule_store', Env::get('SCHEDULE_CACHE_DRIVER', function () { - return Env::get('SCHEDULE_CACHE_STORE'); - })); + return $this->app->make('config')->get('cache.schedule_store'); } /** diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index 05a2a2f8c..8514576e3 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -951,7 +951,7 @@ public function shouldRenderJsonWhen(callable $callback): static */ protected function prepareResponse(Request $request, Throwable $e): Response|RedirectResponse { - if (! $this->isHttpException($e) && config('app.debug')) { + if (! $this->isHttpException($e) && config()->boolean('app.debug')) { return $this->toHypervelResponse($this->convertExceptionToResponse($e), $e)->prepare($request); } @@ -983,7 +983,7 @@ protected function convertExceptionToResponse(Throwable $e): SymfonyResponse protected function renderExceptionContent(Throwable $e): string { try { - if (config('app.debug')) { + if (config()->boolean('app.debug')) { if ($this->container->bound(ExceptionRenderer::class)) { return $this->renderExceptionWithCustomRenderer($e); } @@ -992,9 +992,9 @@ protected function renderExceptionContent(Throwable $e): string } } - return $this->renderExceptionWithSymfony($e, config('app.debug')); + return $this->renderExceptionWithSymfony($e, config()->boolean('app.debug')); } catch (Throwable $e) { - return $this->renderExceptionWithSymfony($e, config('app.debug')); + return $this->renderExceptionWithSymfony($e, config()->boolean('app.debug')); } } @@ -1037,7 +1037,7 @@ protected function renderHttpException(HttpExceptionInterface $e): SymfonyRespon $e->getHeaders() ); } catch (Throwable $t) { - config('app.debug') && throw $t; + config()->boolean('app.debug') && throw $t; $this->report($t); } @@ -1114,7 +1114,7 @@ protected function prepareJsonResponse(Request $request, Throwable $e): JsonResp */ protected function convertExceptionToArray(Throwable $e): array { - return config('app.debug') ? [ + return config()->boolean('app.debug') ? [ 'message' => $e->getMessage(), 'exception' => get_class($e), 'file' => $e->getFile(), diff --git a/src/foundation/src/Exceptions/RegisterErrorViewPaths.php b/src/foundation/src/Exceptions/RegisterErrorViewPaths.php index 3ce189d45..543e0e0f9 100644 --- a/src/foundation/src/Exceptions/RegisterErrorViewPaths.php +++ b/src/foundation/src/Exceptions/RegisterErrorViewPaths.php @@ -4,7 +4,6 @@ namespace Hypervel\Foundation\Exceptions; -use Hypervel\Support\Collection; use Hypervel\Support\Facades\View; class RegisterErrorViewPaths @@ -18,7 +17,7 @@ public function __invoke() return; } - View::replaceNamespace('errors', Collection::make(config('view.paths'))->map(function ($path) { + View::replaceNamespace('errors', config()->collection('view.paths')->map(function ($path) { return "{$path}/errors"; })->push(__DIR__ . '/views')->all()); } diff --git a/src/foundation/src/Http/MaintenanceModeBypassCookie.php b/src/foundation/src/Http/MaintenanceModeBypassCookie.php index c0f71e7bd..183b461ac 100644 --- a/src/foundation/src/Http/MaintenanceModeBypassCookie.php +++ b/src/foundation/src/Http/MaintenanceModeBypassCookie.php @@ -19,7 +19,7 @@ public static function create(string $key): Cookie return new Cookie('hypervel_maintenance', base64_encode(json_encode([ 'expires_at' => $expiresAt->getTimestamp(), 'mac' => hash_hmac('sha256', (string) $expiresAt->getTimestamp(), $key), - ])), $expiresAt, config('session.path'), config('session.domain')); + ])), $expiresAt, config()->string('session.path'), config('session.domain')); } /** diff --git a/src/foundation/src/Http/Middleware/PreventRequestForgery.php b/src/foundation/src/Http/Middleware/PreventRequestForgery.php index 21031223f..978df5a3e 100644 --- a/src/foundation/src/Http/Middleware/PreventRequestForgery.php +++ b/src/foundation/src/Http/Middleware/PreventRequestForgery.php @@ -175,7 +175,7 @@ public function shouldAddXsrfTokenCookie(): bool */ protected function addCookieToResponse(Request $request, Response $response): Response { - $config = config('session'); + $config = config()->array('session'); if ($response instanceof Responsable) { $response = $response->toResponse($request); @@ -200,8 +200,8 @@ protected function newCookie(Request $request, array $config): Cookie $config['secure'], false, false, - $config['same_site'] ?? null, - $config['partitioned'] ?? false + $config['same_site'], + $config['partitioned'] ); } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithEnvironment.php b/src/foundation/src/Testing/Concerns/InteractsWithEnvironment.php index b34dc8ac2..fcc385146 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithEnvironment.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithEnvironment.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Closure; use Hypervel\Support\Env; trait InteractsWithEnvironment @@ -30,4 +31,63 @@ protected function unsetEnvironmentValue(string $key): void Env::flushRepository(); } + + /** + * Run a callback with a temporary environment variable value. + */ + protected function withEnvironmentValue(string $key, ?string $value, Closure $callback): mixed + { + return $this->withEnvironmentValues([$key => $value], $callback); + } + + /** + * Run a callback with temporary environment variable values. + * + * @param array $values + */ + protected function withEnvironmentValues(array $values, Closure $callback): mixed + { + $originalValues = []; + + foreach ($values as $key => $value) { + $originalValues[$key] = [ + 'putenv' => getenv($key), + 'server_exists' => array_key_exists($key, $_SERVER), + 'server' => $_SERVER[$key] ?? null, + 'env_exists' => array_key_exists($key, $_ENV), + 'env' => $_ENV[$key] ?? null, + ]; + + $value === null + ? putenv($key) + : putenv("{$key}={$value}"); + unset($_SERVER[$key], $_ENV[$key]); + } + + Env::flushRepository(); + + try { + return $callback(); + } finally { + foreach ($originalValues as $key => $value) { + $value['putenv'] === false + ? putenv($key) + : putenv("{$key}={$value['putenv']}"); + + if ($value['server_exists']) { + $_SERVER[$key] = $value['server']; + } else { + unset($_SERVER[$key]); + } + + if ($value['env_exists']) { + $_ENV[$key] = $value['env']; + } else { + unset($_ENV[$key]); + } + } + + Env::flushRepository(); + } + } } diff --git a/src/foundation/src/Testing/DatabaseTruncation.php b/src/foundation/src/Testing/DatabaseTruncation.php index 8e9a93bdb..c8ce9a8a0 100644 --- a/src/foundation/src/Testing/DatabaseTruncation.php +++ b/src/foundation/src/Testing/DatabaseTruncation.php @@ -151,10 +151,8 @@ protected function tablesToTruncate(ConnectionInterface $connection, ?string $co */ protected function exceptTables(ConnectionInterface $connection, ?string $connectionName): array { - $migrations = $this->app->make('config')->get('database.migrations'); - - $migrationsTable = is_array($migrations) ? ($migrations['table'] ?? 'migrations') : $migrations; - $migrationsTable = $connection->getTablePrefix() . $migrationsTable; + $migrationsTable = $connection->getTablePrefix() + . $this->app->make('config')->string('database.migrations.table'); return property_exists($this, 'exceptTables') && is_array($this->exceptTables) ? array_merge( diff --git a/src/foundation/src/Testing/RedisTestConfiguration.php b/src/foundation/src/Testing/RedisTestConfiguration.php index bf8ddaecf..e25ed4bad 100644 --- a/src/foundation/src/Testing/RedisTestConfiguration.php +++ b/src/foundation/src/Testing/RedisTestConfiguration.php @@ -98,15 +98,22 @@ public static function configure(Repository $config, string|false $token): void ); } - $connection['database'] = $database; - if ($usesCluster) { - unset($connection['host'], $connection['port']); + unset( + $connection['url'], + $connection['host'], + $connection['port'], + $connection['database'], + $connection['name'], + $connection['sentinel'], + ); $connection['cluster'] = [ 'enabled' => true, 'seeds' => $clusterSeeds, ]; + } else { + $connection['database'] = $database; } $config->set("database.redis.{$name}", $connection); diff --git a/src/foundation/src/resources/health-up.blade.php b/src/foundation/src/resources/health-up.blade.php index 4538f5cd2..10843b112 100644 --- a/src/foundation/src/resources/health-up.blade.php +++ b/src/foundation/src/resources/health-up.blade.php @@ -4,7 +4,7 @@ - {{ config('app.name', 'Hypervel') }} + {{ config('app.name') ?? 'Hypervel' }} diff --git a/src/hashing/src/HashManager.php b/src/hashing/src/HashManager.php index 7b3734567..05f2d36ff 100644 --- a/src/hashing/src/HashManager.php +++ b/src/hashing/src/HashManager.php @@ -18,7 +18,7 @@ class HashManager extends Manager implements Hasher */ public function createBcryptDriver(): BcryptHasher { - return new BcryptHasher($this->config->array('hashing.bcrypt', [])); + return new BcryptHasher($this->config->array('hashing.bcrypt')); } /** @@ -26,7 +26,7 @@ public function createBcryptDriver(): BcryptHasher */ public function createArgonDriver(): ArgonHasher { - return new ArgonHasher($this->config->array('hashing.argon', [])); + return new ArgonHasher($this->config->array('hashing.argon')); } /** @@ -34,7 +34,7 @@ public function createArgonDriver(): ArgonHasher */ public function createArgon2idDriver(): Argon2IdHasher { - return new Argon2IdHasher($this->config->array('hashing.argon', [])); + return new Argon2IdHasher($this->config->array('hashing.argon')); } /** @@ -98,6 +98,6 @@ public function verifyConfiguration(string $hashedValue): bool */ public function getDefaultDriver(): string { - return $this->config->string('hashing.driver', 'bcrypt'); + return $this->config->string('hashing.driver'); } } diff --git a/src/horizon/config/horizon.php b/src/horizon/config/horizon.php index afe7d4a4b..cd454aa00 100644 --- a/src/horizon/config/horizon.php +++ b/src/horizon/config/horizon.php @@ -12,7 +12,7 @@ | | This name appears in notifications and in the Horizon UI. Unique names | can be useful while running multiple instances of Horizon within an - | application, allowing you to identify the Horizon you're viewing. + | application. An omitted, null, or empty name uses the application name. | */ @@ -24,8 +24,8 @@ |-------------------------------------------------------------------------- | | This is the subdomain where Horizon will be accessible from. If this - | setting is null, Horizon will reside under the same domain as the - | application. Otherwise, this value will serve as the subdomain. + | setting is omitted or null, Horizon will reside under the same domain + | as the application. Otherwise, this value will serve as the subdomain. | */ @@ -36,10 +36,10 @@ | Horizon Path |-------------------------------------------------------------------------- | - | This is the URI path where Horizon will be accessible from. Feel free - | to change this path to anything you like. The proxy path prefixes it - | when Horizon is served from a subdirectory behind a reverse proxy, so - | the dashboard can still reach its own internal API. + | These required values define how Horizon is reached. The path registers + | Horizon's application routes. The proxy path prefixes browser requests + | when a reverse proxy strips an external subdirectory before forwarding + | them to Hypervel. Use an empty proxy path when no prefix is needed. | */ @@ -95,7 +95,8 @@ | | This option allows you to configure when the LongWaitDetected event | will be fired. Every connection / queue combination may have its - | own, unique threshold (in seconds) before this event is fired. + | own threshold in seconds. Unlisted combinations use 60 seconds, + | while a threshold of zero disables the event for that queue. | */ @@ -109,8 +110,9 @@ |-------------------------------------------------------------------------- | | Here you can configure for how long (in minutes) you desire Horizon to - | persist the recent and failed jobs. Typically, recent jobs are kept - | for one hour while all failed jobs are stored for an entire week. + | persist the recent and failed jobs. Omitted recent, pending, and + | completed values use one hour. Failed and monitored values use one + | week, while recent_failed inherits the effective failed value. | */ @@ -131,6 +133,7 @@ | Silencing a job will instruct Horizon to not place the job in the list | of completed jobs within the Horizon dashboard. This setting may be | used to fully remove any noisy jobs from the completed jobs list. + | Omitted class and tag lists are treated as empty. | */ @@ -148,9 +151,9 @@ |-------------------------------------------------------------------------- | | Here you can configure how many snapshots should be kept to display in - | the metrics graph. This works with the `horizon:snapshot` schedule to - | define retention. The snapshot lock prevents overlapping - | `horizon:snapshot` runs and should match their interval in seconds. + | the metrics graph. Omitted job and queue values retain 24 snapshots. + | The snapshot lock prevents overlapping `horizon:snapshot` runs and + | defaults to 300 seconds when omitted. | */ @@ -190,6 +193,20 @@ 'memory_limit' => 64, + /* + |-------------------------------------------------------------------------- + | Horizon Environment + |-------------------------------------------------------------------------- + | + | This advanced override selects which provisioning environment Horizon + | uses independently of the application environment. Omit it or set it + | to null to inherit app.env. The horizon command's --environment option + | takes precedence over both settings. + | + */ + + 'env' => env('HORIZON_ENV'), + /* |-------------------------------------------------------------------------- | Queue Worker Configuration @@ -198,6 +215,7 @@ | Here you may define the queue worker settings used by your application | in all environments. These supervisors and settings handle all your | queued jobs and will be provisioned by Horizon during deployment. + | Omitting defaults applies no shared supervisor options. | */ diff --git a/src/horizon/resources/views/layout.blade.php b/src/horizon/resources/views/layout.blade.php index a876adb07..bc831dbfd 100644 --- a/src/horizon/resources/views/layout.blade.php +++ b/src/horizon/resources/views/layout.blade.php @@ -7,7 +7,7 @@ - Horizon{{ config('horizon.name') ? ' - ' . config('horizon.name') : '' }} + Horizon{{ config()->string('horizon.name') ? ' - ' . config()->string('horizon.name') : '' }} @@ -32,7 +32,7 @@

- Hypervel Horizon{{ config('horizon.name') ? ' - ' . config('horizon.name') : '' }} + Hypervel Horizon{{ config()->string('horizon.name') ? ' - ' . config()->string('horizon.name') : '' }}

diff --git a/src/horizon/src/Connectors/RedisConnector.php b/src/horizon/src/Connectors/RedisConnector.php index 3b90d4d09..5c46c3942 100644 --- a/src/horizon/src/Connectors/RedisConnector.php +++ b/src/horizon/src/Connectors/RedisConnector.php @@ -6,7 +6,6 @@ use Hypervel\Horizon\RedisQueue; use Hypervel\Queue\Connectors\RedisConnector as BaseConnector; -use Hypervel\Support\Arr; class RedisConnector extends BaseConnector { @@ -18,10 +17,11 @@ public function connect(array $config): RedisQueue return new RedisQueue( $this->redis, $config['queue'], - Arr::get($config, 'connection', $this->connection), - Arr::get($config, 'retry_after', 60), - Arr::get($config, 'block_for', null), - Arr::get($config, 'after_commit', false) + $config['connection'] ?? $this->connection, + $config['retry_after'] ?? RedisQueue::DEFAULT_RETRY_AFTER, + $config['block_for'] ?? null, + $config['after_commit'] ?? true, + $config['migration_batch_size'] ?? RedisQueue::DEFAULT_MIGRATION_BATCH_SIZE, ); } } diff --git a/src/horizon/src/Console/ClearCommand.php b/src/horizon/src/Console/ClearCommand.php index 92d846517..e03ec1db1 100644 --- a/src/horizon/src/Console/ClearCommand.php +++ b/src/horizon/src/Console/ClearCommand.php @@ -40,7 +40,7 @@ public function handle(JobRepository $jobRepository, QueueManager $manager): ?in $connection = $this->argument('connection'); if ($connection === null || $connection === '') { - $connection = array_first(config('horizon.defaults'))['connection'] ?? 'redis'; + $connection = array_first(config()->array('horizon.defaults', []))['connection'] ?? 'redis'; } $queue = $this->getQueue($connection); diff --git a/src/horizon/src/Console/HorizonCommand.php b/src/horizon/src/Console/HorizonCommand.php index 4e029a573..21c3f247f 100644 --- a/src/horizon/src/Console/HorizonCommand.php +++ b/src/horizon/src/Console/HorizonCommand.php @@ -34,7 +34,7 @@ public function handle(MasterSupervisorRepository $masters): int return self::SUCCESS; } - $environment = $this->option('environment') ?? config('horizon.env') ?? config('app.env'); + $environment = $this->option('environment') ?? config('horizon.env') ?? config()->string('app.env'); $master = (new MasterSupervisor($environment))->handleOutputUsing(function ($type, $line) { $this->output->write($line); diff --git a/src/horizon/src/Console/SnapshotCommand.php b/src/horizon/src/Console/SnapshotCommand.php index 76f97050e..ffc5e6db3 100644 --- a/src/horizon/src/Console/SnapshotCommand.php +++ b/src/horizon/src/Console/SnapshotCommand.php @@ -27,7 +27,10 @@ class SnapshotCommand extends Command */ public function handle(Lock $lock, MetricsRepository $metrics): void { - $seconds = config()->integer('horizon.metrics.snapshot_lock', 300) - 30; + $seconds = config()->integer( + 'horizon.metrics.snapshot_lock', + 300, + ) - 30; if ($lock->get('metrics:snapshot', $seconds)) { $metrics->snapshot(); diff --git a/src/horizon/src/Http/Controllers/DashboardStatsController.php b/src/horizon/src/Http/Controllers/DashboardStatsController.php index 5ea0e576a..ae53b445b 100644 --- a/src/horizon/src/Http/Controllers/DashboardStatsController.php +++ b/src/horizon/src/Http/Controllers/DashboardStatsController.php @@ -8,6 +8,7 @@ use Hypervel\Horizon\Contracts\MasterSupervisorRepository; use Hypervel\Horizon\Contracts\MetricsRepository; use Hypervel\Horizon\Contracts\SupervisorRepository; +use Hypervel\Horizon\Repositories\RedisJobRepository; use Hypervel\Horizon\WaitTimeCalculator; class DashboardStatsController extends Controller @@ -17,13 +18,21 @@ class DashboardStatsController extends Controller */ public function index(): array { + $failedJobRetention = config()->integer( + 'horizon.trim.failed', + RedisJobRepository::DEFAULT_FAILED_JOB_RETENTION, + ); + return [ 'failedJobs' => app(JobRepository::class)->countRecentlyFailed(), 'jobsPerMinute' => app(MetricsRepository::class)->jobsProcessedPerMinute(), 'pausedMasters' => $this->totalPausedMasters(), 'periods' => [ - 'failedJobs' => config('horizon.trim.recent_failed', config('horizon.trim.failed')), - 'recentJobs' => config('horizon.trim.recent'), + 'failedJobs' => config()->integer('horizon.trim.recent_failed', $failedJobRetention), + 'recentJobs' => config()->integer( + 'horizon.trim.recent', + RedisJobRepository::DEFAULT_RECENT_JOB_RETENTION, + ), ], 'processes' => $this->totalProcessCount(), 'queueWithMaxRuntime' => app(MetricsRepository::class)->queueWithMaximumRuntime(), diff --git a/src/horizon/src/Http/Controllers/MasterSupervisorController.php b/src/horizon/src/Http/Controllers/MasterSupervisorController.php index fead3dc20..90e3e6796 100644 --- a/src/horizon/src/Http/Controllers/MasterSupervisorController.php +++ b/src/horizon/src/Http/Controllers/MasterSupervisorController.php @@ -25,7 +25,7 @@ public function index( return $masters->each(function ($master, $name) use ($supervisors) { $master->supervisors = ($supervisors->get($name) ?? collect()) ->merge( - collect(ProvisioningPlan::get($name)->plan[$master->environment ?? config('horizon.env') ?? config('app.env')] ?? []) + collect(ProvisioningPlan::get($name)->plan[$master->environment ?? config('horizon.env') ?? config()->string('app.env')] ?? []) ->map(function ($value, $key) use ($name) { return (object) [ 'name' => $name . ':' . $key, diff --git a/src/horizon/src/JobPayload.php b/src/horizon/src/JobPayload.php index 0831c0a86..1906fdaad 100644 --- a/src/horizon/src/JobPayload.php +++ b/src/horizon/src/JobPayload.php @@ -143,9 +143,9 @@ protected function shouldBeSilenced(mixed $job, array $tags): bool $jobClass = is_string($underlyingJob) ? $underlyingJob : get_class($underlyingJob); - return in_array($jobClass, config('horizon.silenced', [])) + return in_array($jobClass, config()->array('horizon.silenced', []), true) || is_a($jobClass, Silenced::class, true) - || count(array_intersect($tags, config('horizon.silenced_tags', []))) > 0; + || count(array_intersect($tags, config()->array('horizon.silenced_tags', []))) > 0; } /** diff --git a/src/horizon/src/Listeners/StoreTagsForFailedJob.php b/src/horizon/src/Listeners/StoreTagsForFailedJob.php index d5208899c..4a77bac8f 100644 --- a/src/horizon/src/Listeners/StoreTagsForFailedJob.php +++ b/src/horizon/src/Listeners/StoreTagsForFailedJob.php @@ -6,6 +6,7 @@ use Hypervel\Horizon\Contracts\TagRepository; use Hypervel\Horizon\Events\JobFailed; +use Hypervel\Horizon\Repositories\RedisJobRepository; class StoreTagsForFailedJob { @@ -29,7 +30,7 @@ public function handle(JobFailed $event): void })->all(); $this->tags->addTemporary( - config('horizon.trim.failed', 10080), + config()->integer('horizon.trim.failed', RedisJobRepository::DEFAULT_FAILED_JOB_RETENTION), $event->payload->id(), $tags ); diff --git a/src/horizon/src/Listeners/TrimFailedJobs.php b/src/horizon/src/Listeners/TrimFailedJobs.php index 4cccf8787..b87017aa7 100644 --- a/src/horizon/src/Listeners/TrimFailedJobs.php +++ b/src/horizon/src/Listeners/TrimFailedJobs.php @@ -6,6 +6,7 @@ use Hypervel\Horizon\Contracts\JobRepository; use Hypervel\Horizon\Events\MasterSupervisorLooped; +use Hypervel\Horizon\Repositories\RedisJobRepository; use Hypervel\Support\CarbonImmutable; class TrimFailedJobs @@ -27,7 +28,7 @@ public function handle(MasterSupervisorLooped $event): void { if (! isset($this->lastTrimmed)) { $this->frequency = max(1, intdiv( - config('horizon.trim.failed', 10080), + config()->integer('horizon.trim.failed', RedisJobRepository::DEFAULT_FAILED_JOB_RETENTION), 12 )); diff --git a/src/horizon/src/Listeners/TrimMonitoredJobs.php b/src/horizon/src/Listeners/TrimMonitoredJobs.php index 0f6de5578..a5ee0aa6d 100644 --- a/src/horizon/src/Listeners/TrimMonitoredJobs.php +++ b/src/horizon/src/Listeners/TrimMonitoredJobs.php @@ -6,6 +6,7 @@ use Hypervel\Horizon\Contracts\JobRepository; use Hypervel\Horizon\Events\MasterSupervisorLooped; +use Hypervel\Horizon\Repositories\RedisJobRepository; use Hypervel\Support\CarbonImmutable; class TrimMonitoredJobs @@ -27,7 +28,7 @@ public function handle(MasterSupervisorLooped $event): void { if (! isset($this->lastTrimmed)) { $this->frequency = max(1, intdiv( - config('horizon.trim.monitored', 10080), + config()->integer('horizon.trim.monitored', RedisJobRepository::DEFAULT_MONITORED_JOB_RETENTION), 12 )); diff --git a/src/horizon/src/Notifications/LongWaitDetected.php b/src/horizon/src/Notifications/LongWaitDetected.php index 8e3d5a996..9255079eb 100644 --- a/src/horizon/src/Notifications/LongWaitDetected.php +++ b/src/horizon/src/Notifications/LongWaitDetected.php @@ -50,7 +50,7 @@ public function toMail(mixed $notifiable): MailMessage { return (new MailMessage) ->error() - ->subject(config('horizon.name') . ': Long Queue Wait Detected') + ->subject(config()->string('horizon.name') . ': Long Queue Wait Detected') ->greeting('Oh no! Something needs your attention.') ->line(sprintf( 'The "%s" queue on the "%s" connection has a wait time of %s seconds.', @@ -71,7 +71,7 @@ public function toSlack(mixed $notifiable): ChannelIdSlackMessage|SlackMessage $content = sprintf( '[%s] The "%s" queue on the "%s" connection has a wait time of %s seconds.', - config('horizon.name'), + config()->string('horizon.name'), $this->longWaitQueue, $this->longWaitConnection, $this->seconds diff --git a/src/horizon/src/ProvisioningPlan.php b/src/horizon/src/ProvisioningPlan.php index b3c2da4f1..369e73e37 100644 --- a/src/horizon/src/ProvisioningPlan.php +++ b/src/horizon/src/ProvisioningPlan.php @@ -42,7 +42,11 @@ public function __construct( */ public static function get(string $master): static { - return new static($master, config('horizon.environments'), config('horizon.defaults', [])); + return new static( + $master, + config()->array('horizon.environments'), + config()->array('horizon.defaults', []), + ); } /** diff --git a/src/horizon/src/Repositories/RedisJobRepository.php b/src/horizon/src/Repositories/RedisJobRepository.php index ca0a71c2e..edf3a7de3 100644 --- a/src/horizon/src/Repositories/RedisJobRepository.php +++ b/src/horizon/src/Repositories/RedisJobRepository.php @@ -19,6 +19,12 @@ class RedisJobRepository implements JobRepository { use UsesClusterAwarePipeline; + public const int DEFAULT_RECENT_JOB_RETENTION = 60; + + public const int DEFAULT_FAILED_JOB_RETENTION = 10_080; + + public const int DEFAULT_MONITORED_JOB_RETENTION = 10_080; + /** * The keys stored on the job hashes. */ @@ -64,12 +70,12 @@ class RedisJobRepository implements JobRepository public function __construct( public Redis $redis ) { - $this->recentJobExpires = (int) config('horizon.trim.recent', 60); - $this->pendingJobExpires = (int) config('horizon.trim.pending', 60); - $this->completedJobExpires = (int) config('horizon.trim.completed', 60); - $this->failedJobExpires = (int) config('horizon.trim.failed', 10080); - $this->recentFailedJobExpires = (int) config('horizon.trim.recent_failed', $this->failedJobExpires); - $this->monitoredJobExpires = (int) config('horizon.trim.monitored', 10080); + $this->recentJobExpires = config()->integer('horizon.trim.recent', self::DEFAULT_RECENT_JOB_RETENTION); + $this->pendingJobExpires = config()->integer('horizon.trim.pending', 60); + $this->completedJobExpires = config()->integer('horizon.trim.completed', 60); + $this->failedJobExpires = config()->integer('horizon.trim.failed', self::DEFAULT_FAILED_JOB_RETENTION); + $this->recentFailedJobExpires = config()->integer('horizon.trim.recent_failed', $this->failedJobExpires); + $this->monitoredJobExpires = config()->integer('horizon.trim.monitored', self::DEFAULT_MONITORED_JOB_RETENTION); } /** diff --git a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php index 23afacdbc..753e65f07 100644 --- a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php +++ b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php @@ -66,7 +66,11 @@ public function get(array $names): array return collect($records)->map(function ($record) { return $record['name'] - ? (object) array_merge($record, ['supervisors' => json_decode($record['supervisors'], true)]) + ? (object) array_merge($record, [ + 'supervisors' => json_decode($record['supervisors'], true), + // Redis stores null hash values as empty strings. + 'environment' => $record['environment'] === '' ? null : $record['environment'], + ]) : null; })->filter()->all(); } diff --git a/src/horizon/src/Repositories/RedisMetricsRepository.php b/src/horizon/src/Repositories/RedisMetricsRepository.php index f0c9cfc83..3f4bc7ad6 100644 --- a/src/horizon/src/Repositories/RedisMetricsRepository.php +++ b/src/horizon/src/Repositories/RedisMetricsRepository.php @@ -228,7 +228,10 @@ protected function storeSnapshotForJob(string $job): void $this->connection()->zRemRangeByRank( 'snapshot:' . $key, 0, - -abs(1 + config('horizon.metrics.trim_snapshots.job', 24)) + -abs(1 + config()->integer( + 'horizon.metrics.trim_snapshots.job', + 24, + )) ); } @@ -253,7 +256,10 @@ protected function storeSnapshotForQueue(string $queue): void $this->connection()->zRemRangeByRank( 'snapshot:' . $key, 0, - -abs(1 + config('horizon.metrics.trim_snapshots.queue', 24)) + -abs(1 + config()->integer( + 'horizon.metrics.trim_snapshots.queue', + 24, + )) ); } diff --git a/src/http/src/HttpServiceProvider.php b/src/http/src/HttpServiceProvider.php index e549c75fb..ebeb2b5a3 100644 --- a/src/http/src/HttpServiceProvider.php +++ b/src/http/src/HttpServiceProvider.php @@ -76,7 +76,7 @@ protected function registerRequestFactory(): void { $this->app->bind('request', function ($app) { return RequestContext::getOrNull() - ?? Request::create($app->make('config')->string('app.url')); + ?? Request::create($app->make('config')->get('app.url') ?? 'http://localhost'); }); } diff --git a/src/http/src/Middleware/TrustHosts.php b/src/http/src/Middleware/TrustHosts.php index 5b8879f62..c50315009 100644 --- a/src/http/src/Middleware/TrustHosts.php +++ b/src/http/src/Middleware/TrustHosts.php @@ -140,7 +140,13 @@ protected function shouldSpecifyTrustedHosts(): bool */ protected function allSubdomainsOfApplicationUrl(): ?string { - if ($host = parse_url($this->app->make('config')->string('app.url'), PHP_URL_HOST)) { + $url = $this->app->make('config')->get('app.url'); + + if ($url === null) { + return null; + } + + if ($host = parse_url($url, PHP_URL_HOST)) { return '^(.+\.)?' . preg_quote($host) . '$'; } diff --git a/src/inertia/config/inertia.php b/src/inertia/config/inertia.php index c3f978bf8..e25b77084 100644 --- a/src/inertia/config/inertia.php +++ b/src/inertia/config/inertia.php @@ -11,8 +11,9 @@ | These options configure if and how Inertia uses Server Side Rendering | to pre-render the initial visits made to your application's pages. | - | You can specify a custom SSR bundle path, or omit it to let Inertia - | try and automatically detect it for you. + | You can specify a custom SSR bundle path, or set it to null to let + | Inertia try and automatically detect it for you. + | Omitted SSR members use the defaults shown below. | | Do note that enabling these options will NOT automatically make SSR work, | as a separate rendering service needs to be available. To learn more, @@ -33,7 +34,7 @@ 'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true), - // 'bundle' => base_path('bootstrap/ssr/ssr.mjs'), + 'bundle' => null, /* |-------------------------------------------------------------------------- @@ -89,7 +90,8 @@ | catching missing or misnamed components. | | The `paths` and `extensions` options define where to look for page - | components and which file extensions to consider. + | components and which file extensions to consider. They are required; + | `ensure_pages_exist` may be omitted and defaults to false. | */ @@ -120,7 +122,7 @@ | the `pages.extensions` specified above. | | You can disable this behavior by setting `ensure_pages_exist` - | to false. + | to false. Omission keeps it enabled. | */ @@ -149,7 +151,8 @@ | Enable `encrypt` to encrypt page data before it is stored in the | browser's history state, preventing sensitive information from | being accessible after logout. Can also be enabled per-request - | or via the `inertia.encrypt` middleware. + | or via the `inertia.encrypt` middleware. Omission leaves encryption + | disabled. | */ diff --git a/src/inertia/src/Commands/StartSsr.php b/src/inertia/src/Commands/StartSsr.php index 3ba33751e..95708e89b 100644 --- a/src/inertia/src/Commands/StartSsr.php +++ b/src/inertia/src/Commands/StartSsr.php @@ -29,7 +29,7 @@ class StartSsr extends Command */ public function handle(): int { - if (! config('inertia.ssr.enabled', true)) { + if (! config()->boolean('inertia.ssr.enabled', true)) { $this->error('Inertia SSR is not enabled. Enable it via the `inertia.ssr.enabled` config option.'); return self::FAILURE; @@ -52,9 +52,9 @@ public function handle(): int $this->warn('Using a default bundle instead: "' . $bundle . '"'); } - $runtime = $this->option('runtime') ?? config('inertia.ssr.runtime', 'node'); + $runtime = $this->option('runtime') ?? config()->string('inertia.ssr.runtime', 'node'); - if (config('inertia.ssr.ensure_runtime_exists', false) && ! (new ExecutableFinder)->find($runtime)) { + if (config()->boolean('inertia.ssr.ensure_runtime_exists', false) && ! (new ExecutableFinder)->find($runtime)) { $this->error('SSR runtime "' . $runtime . '" could not be found.'); return self::FAILURE; diff --git a/src/inertia/src/PropsResolver.php b/src/inertia/src/PropsResolver.php index 25da84569..fc3911919 100644 --- a/src/inertia/src/PropsResolver.php +++ b/src/inertia/src/PropsResolver.php @@ -172,7 +172,7 @@ protected function resolveSharedProps(array $shared): array { $resolved = $this->resolvePropertyProviders($shared); - if (! config('inertia.expose_shared_prop_keys', true)) { + if (! config()->boolean('inertia.expose_shared_prop_keys')) { return $resolved; } diff --git a/src/inertia/src/ResponseFactory.php b/src/inertia/src/ResponseFactory.php index 25e1618c4..aede177aa 100644 --- a/src/inertia/src/ResponseFactory.php +++ b/src/inertia/src/ResponseFactory.php @@ -306,7 +306,7 @@ public function render(mixed $component, mixed $props = []): Response throw new InvalidArgumentException('Component argument must be of type string or a string BackedEnum'); } - if (config('inertia.pages.ensure_pages_exist', false)) { + if (config()->boolean('inertia.pages.ensure_pages_exist', false)) { $this->findComponentOrFail($component); } @@ -325,7 +325,7 @@ public function render(mixed $component, mixed $props = []): Response $props, $state->rootView, $this->getVersion(), - $state->encryptHistory ?? (bool) config('inertia.history.encrypt', false), + $state->encryptHistory ?? config()->boolean('inertia.history.encrypt', false), $state->urlResolver, ); } diff --git a/src/inertia/src/Ssr/HttpGateway.php b/src/inertia/src/Ssr/HttpGateway.php index e3da39239..a9010484a 100644 --- a/src/inertia/src/Ssr/HttpGateway.php +++ b/src/inertia/src/Ssr/HttpGateway.php @@ -65,8 +65,8 @@ protected function ssrClient(): ClientInterface } return self::$ssrClient ??= new Client([ - 'connect_timeout' => (int) config('inertia.ssr.connect_timeout', 2), - 'timeout' => (int) config('inertia.ssr.timeout', 5), + 'connect_timeout' => config()->integer('inertia.ssr.connect_timeout', 2), + 'timeout' => config()->integer('inertia.ssr.timeout', 5), 'cookies' => false, 'http_errors' => false, ]); @@ -207,7 +207,7 @@ protected function handleSsrFailure(array $page, ?array $error): void event($event); // Throw an exception if configured (useful for E2E testing) - if (config('inertia.ssr.throw_on_error', false)) { + if (config()->boolean('inertia.ssr.throw_on_error', false)) { throw SsrException::fromEvent($event); } } @@ -226,7 +226,7 @@ protected function ssrIsEnabled(Request $request): bool $enabled = $state->ssrDisabled !== null ? ! $this->resolveCallable($state->ssrDisabled) - : config('inertia.ssr.enabled', true); + : config()->boolean('inertia.ssr.enabled', true); return $enabled && ! $this->inExceptArray($request); } @@ -266,7 +266,7 @@ public function shutdown(): bool */ protected function shouldEnsureBundleExists(): bool { - return (bool) config('inertia.ssr.ensure_bundle_exists', true); + return config()->boolean('inertia.ssr.ensure_bundle_exists', true); } /** @@ -283,7 +283,7 @@ protected function bundleExists(): bool public function getProductionUrl(string $path = '/'): string { $path = Str::start($path, '/'); - $baseUrl = rtrim((string) config('inertia.ssr.url', 'http://127.0.0.1:13714'), '/'); + $baseUrl = rtrim(config()->string('inertia.ssr.url', 'http://127.0.0.1:13714'), '/'); return $baseUrl . $path; } @@ -334,7 +334,7 @@ protected function isValidSsrResponse(mixed $data): bool private function armTransportBackoff(): void { self::$ssrUnavailableUntil = microtime(true) - + (float) config('inertia.ssr.backoff', 5.0); + + config()->float('inertia.ssr.backoff', 5.0); } /** diff --git a/src/inertia/src/Testing/AssertableInertia.php b/src/inertia/src/Testing/AssertableInertia.php index fa71635c7..beedd677e 100644 --- a/src/inertia/src/Testing/AssertableInertia.php +++ b/src/inertia/src/Testing/AssertableInertia.php @@ -96,7 +96,7 @@ public function component(?string $value = null, $shouldExist = null): self { PHPUnit::assertSame($value, $this->component, 'Unexpected Inertia page component.'); - if ($shouldExist || (is_null($shouldExist) && config('inertia.testing.ensure_pages_exist', true))) { + if ($shouldExist || (is_null($shouldExist) && config()->boolean('inertia.testing.ensure_pages_exist', true))) { try { app('inertia.view-finder')->find($value); } catch (InvalidArgumentException $exception) { diff --git a/src/jwt/config/jwt.php b/src/jwt/config/jwt.php index 592dced40..f56f41917 100644 --- a/src/jwt/config/jwt.php +++ b/src/jwt/config/jwt.php @@ -233,7 +233,7 @@ | */ - 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', false), + 'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', false), /* |-------------------------------------------------------------------------- @@ -245,7 +245,7 @@ | */ - 'refresh_iat' => env('JWT_REFRESH_IAT', false), + 'refresh_iat' => (bool) env('JWT_REFRESH_IAT', false), /* |-------------------------------------------------------------------------- @@ -257,7 +257,7 @@ | */ - 'lock_subject' => env('JWT_LOCK_SUBJECT', true), + 'lock_subject' => (bool) env('JWT_LOCK_SUBJECT', true), /* |-------------------------------------------------------------------------- @@ -295,7 +295,8 @@ | Providers |-------------------------------------------------------------------------- | - | Specify the various providers used throughout the package. + | Specify the various providers used throughout the package. Omitted + | members use the implementations shown below. | */ diff --git a/src/jwt/src/JwtGuard.php b/src/jwt/src/JwtGuard.php index 1fdaa5319..8248c8b4e 100644 --- a/src/jwt/src/JwtGuard.php +++ b/src/jwt/src/JwtGuard.php @@ -34,6 +34,8 @@ class JwtGuard implements Guard use GuardHelpers; use Macroable; + public const int DEFAULT_TTL = 120; + protected const string GUARD_CONTEXT_KEY_PREFIX = '__auth.guards.'; private const string NO_EXPIRY = '__jwt.ttl.no_expiry'; @@ -60,7 +62,7 @@ public function __construct( protected ClaimFactory $claimFactory, protected Parser $parser, protected Container $app, - protected ?int $ttl = 120, + protected ?int $ttl = self::DEFAULT_TTL, ) { $this->provider = $provider; } diff --git a/src/jwt/src/JwtServiceProvider.php b/src/jwt/src/JwtServiceProvider.php index dba01e57e..2c49f34cd 100644 --- a/src/jwt/src/JwtServiceProvider.php +++ b/src/jwt/src/JwtServiceProvider.php @@ -17,6 +17,7 @@ use Hypervel\Jwt\Http\Parser\Parser; use Hypervel\Jwt\Storage\TaggedCache; use Hypervel\Support\ServiceProvider; +use InvalidArgumentException; use RuntimeException; class JwtServiceProvider extends ServiceProvider implements ReloadsConfiguration @@ -131,14 +132,19 @@ protected function registerJwtGuard(): void { $this->callAfterResolving(AuthManager::class, function (AuthManager $authManager) { $authManager->extend('jwt', function ($app, $name, $config) use ($authManager) { - /** @var null|int $ttl */ $ttl = array_key_exists('ttl', $config) ? $config['ttl'] : $app->make('config')->get('jwt.ttl'); + if (! is_int($ttl) && $ttl !== null) { + throw new InvalidArgumentException( + "JWT TTL for auth guard [{$name}] must be an integer or null." + ); + } + $guard = new JwtGuard( name: $name, - provider: $authManager->createUserProvider($config['provider'] ?? null), + provider: $authManager->createUserProvider($config['provider']), jwtManager: $app->make('jwt'), claimFactory: $app->make(ClaimFactory::class), parser: $app->make(Parser::class), diff --git a/src/mail/resources/views/html/layout.blade.php b/src/mail/resources/views/html/layout.blade.php index 1ee32093d..bb909d993 100644 --- a/src/mail/resources/views/html/layout.blade.php +++ b/src/mail/resources/views/html/layout.blade.php @@ -1,7 +1,7 @@ -{{ config('app.name') }} +{{ config()->string('app.name') }} diff --git a/src/mail/resources/views/html/message.blade.php b/src/mail/resources/views/html/message.blade.php index 1a874fc26..d2dc7bebf 100644 --- a/src/mail/resources/views/html/message.blade.php +++ b/src/mail/resources/views/html/message.blade.php @@ -2,7 +2,7 @@ {{-- Header --}} -{{ config('app.name') }} +{{ config()->string('app.name') }} @@ -21,7 +21,7 @@ {{-- Footer --}} -© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }} +© {{ date('Y') }} {{ config()->string('app.name') }}. {{ __('All rights reserved.') }} diff --git a/src/mail/resources/views/text/message.blade.php b/src/mail/resources/views/text/message.blade.php index 80bce2112..693ab1e85 100644 --- a/src/mail/resources/views/text/message.blade.php +++ b/src/mail/resources/views/text/message.blade.php @@ -2,7 +2,7 @@ {{-- Header --}} - {{ config('app.name') }} + {{ config()->string('app.name') }} @@ -21,7 +21,7 @@ {{-- Footer --}} - © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.') + © {{ date('Y') }} {{ config()->string('app.name') }}. @lang('All rights reserved.') diff --git a/src/mail/src/MailServiceProvider.php b/src/mail/src/MailServiceProvider.php index d266b5fee..308ac9db2 100644 --- a/src/mail/src/MailServiceProvider.php +++ b/src/mail/src/MailServiceProvider.php @@ -61,13 +61,10 @@ protected function registerMarkdownRenderer(): void } $this->app->singleton(Markdown::class, function ($app) { - $config = $app->make('config'); - - return new Markdown($app->make('view'), [ - 'theme' => $config->string('mail.markdown.theme', 'default'), - 'paths' => $config->array('mail.markdown.paths', []), - 'extensions' => $config->array('mail.markdown.extensions', []), - ]); + return new Markdown( + $app->make('view'), + $app->make('config')->array('mail.markdown', []), + ); }); } } diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index 6fe6b9f53..440b767be 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -397,7 +397,7 @@ protected function markdownTheme(): string { return $this->theme ?: Container::getInstance()->make('config')->string( 'mail.markdown.theme', - 'default' + 'default', ); } diff --git a/src/notifications/resources/views/email.blade.php b/src/notifications/resources/views/email.blade.php index a7b8c8647..d711c161b 100644 --- a/src/notifications/resources/views/email.blade.php +++ b/src/notifications/resources/views/email.blade.php @@ -40,7 +40,7 @@ {{ $salutation }} @else @lang('Regards,')
-{{ config('app.name') }} +{{ config()->string('app.name') }} @endif {{-- Subcopy --}} @@ -55,4 +55,4 @@ ) [{{ $displayableActionUrl }}]({{ $actionUrl }}) @endisset - \ No newline at end of file + diff --git a/src/passkeys/config/passkeys.php b/src/passkeys/config/passkeys.php index 837d70231..231b807d1 100644 --- a/src/passkeys/config/passkeys.php +++ b/src/passkeys/config/passkeys.php @@ -2,6 +2,11 @@ declare(strict_types=1); +/** @var null|string $appUrl */ +$appUrl = config('app.url'); +$defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); +$defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; + return [ /* |-------------------------------------------------------------------------- @@ -10,11 +15,12 @@ | | The relying party ID represents your application in the WebAuthn protocol. | This is typically your domain (e.g., "example.com"). Passkeys are bound - | to this ID and can only be verified on matching domains. + | to this ID and can only be verified on matching domains. When this + | value is null, Passkeys reports the missing ID when it is first used. | */ - 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', parse_url(config('app.url'), PHP_URL_HOST)), + 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', $defaultRelyingPartyId), /* |-------------------------------------------------------------------------- @@ -23,11 +29,12 @@ | | The origins permitted to complete WebAuthn ceremonies. Passkeys bound | to the relying party ID above will only verify when the browser - | reports one of these origins. Defaults to your application URL. + | reports one of these origins. Defaults to your application URL. When + | that URL is null, the empty list is rejected when origins are used. | */ - 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', [config('app.url')]), + 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', $defaultAllowedOrigins), /* |-------------------------------------------------------------------------- @@ -36,6 +43,7 @@ | | A nonempty secret used to derive a stable WebAuthn user handle from each | user model. Set this explicitly if you rotate your application key. + | When both values are null, Passkeys reports the missing secret when used. | */ @@ -51,7 +59,7 @@ | */ - 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60000), + 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60_000), /* |-------------------------------------------------------------------------- diff --git a/src/passkeys/routes/routes.php b/src/passkeys/routes/routes.php index 0c5d783cd..23e44b00f 100644 --- a/src/passkeys/routes/routes.php +++ b/src/passkeys/routes/routes.php @@ -18,7 +18,7 @@ $managementMiddleware = array_values(array_filter(config()->array('passkeys.management_middleware'))); $middleware = function (string ...$middleware): array { - $throttle = config('passkeys.throttle'); + $throttle = config('passkeys.throttle', 'throttle:6,1'); return array_values(array_filter([...$middleware, $throttle])); }; diff --git a/src/passkeys/src/Passkeys.php b/src/passkeys/src/Passkeys.php index beb26bd10..d013fea10 100644 --- a/src/passkeys/src/Passkeys.php +++ b/src/passkeys/src/Passkeys.php @@ -16,6 +16,8 @@ class Passkeys { + public const int DEFAULT_TIMEOUT = 60_000; + private const string DEFAULT_PASSKEY_MODEL = Passkey::class; private const bool DEFAULT_REGISTERS_ROUTES = true; @@ -47,7 +49,7 @@ public static function relyingPartyId(): string $relyingPartyId = $request instanceof Request ? $callback($request) - : self::config()->string('passkeys.relying_party_id'); + : self::config()->get('passkeys.relying_party_id'); if (! is_string($relyingPartyId) || $relyingPartyId === '') { if ($request instanceof Request) { @@ -88,7 +90,7 @@ public static function allowedOrigins(): array $origins = $request instanceof Request ? $callback($request) - : self::config()->array('passkeys.allowed_origins'); + : self::config()->get('passkeys.allowed_origins'); $origins = is_array($origins) ? array_values(array_filter( $origins, @@ -136,7 +138,7 @@ public static function hasRequestAwareAllowedOrigins(): bool */ public static function timeout(): int { - $timeout = self::config()->integer('passkeys.timeout'); + $timeout = self::config()->integer('passkeys.timeout', self::DEFAULT_TIMEOUT); if ($timeout < 1) { throw new RuntimeException('Passkey timeout must be a positive integer.'); @@ -228,7 +230,7 @@ public static function redirectTo(Request $request): string } } - return self::config()->string('passkeys.redirect'); + return self::config()->string('passkeys.redirect', '/'); } /** @@ -288,9 +290,9 @@ public static function migrationPath(): string */ public static function userHandleSecret(): string { - $secret = self::config()->string('passkeys.user_handle_secret'); + $secret = self::config()->get('passkeys.user_handle_secret'); - if ($secret === '') { + if (! is_string($secret) || $secret === '') { throw new RuntimeException('Passkey user handle secret must not be empty.'); } diff --git a/src/permission/config/permission.php b/src/permission/config/permission.php index 97bec4613..66245df56 100644 --- a/src/permission/config/permission.php +++ b/src/permission/config/permission.php @@ -5,9 +5,18 @@ use Hypervel\Permission\DefaultTeamResolver; use Hypervel\Permission\Models\Permission; use Hypervel\Permission\Models\Role; -use Hypervel\Permission\WildcardPermission; return [ + /* + |-------------------------------------------------------------------------- + | Permission Models + |-------------------------------------------------------------------------- + | + | These models back the package's role and permission records. The team + | and default models may remain null when their related behavior is unused. + | + */ + 'models' => [ /* * The model used to retrieve permissions. @@ -20,16 +29,28 @@ 'role' => Role::class, /* - * The app-owned team model used by the teams feature. + * The app-owned team model used by the teams feature. Set to null when + * teams are disabled or the application does not expose a team model. */ 'team' => null, /* * The model used when raw IDs are passed to reverse-assignment helpers. + * Set to null to use the authenticated guard's user model. */ 'default_model' => null, ], + /* + |-------------------------------------------------------------------------- + | Table and Column Names + |-------------------------------------------------------------------------- + | + | Runtime relationships and the published migrations both use these names. + | Keep any customized values aligned with the application's schema. + | + */ + 'table_names' => [ 'roles' => 'roles', 'permissions' => 'permissions', @@ -39,6 +60,10 @@ ], 'column_names' => [ + /* + * Set these pivot keys to null, or omit them, to use role_id and + * permission_id. An omitted team key uses team_id. + */ 'role_pivot_key' => null, 'permission_pivot_key' => null, 'model_morph_key' => 'model_id', @@ -46,23 +71,32 @@ ], /* - * Register the Gate::before permission check so $user->can('permission') works. + |-------------------------------------------------------------------------- + | Permission Checks and Assignment Events + |-------------------------------------------------------------------------- + | + | The package may register its Gate permission hook and dispatch the role + | and permission attached and detached events. Events are only constructed + | when a listener is registered for the corresponding event class. + | */ + 'register_permission_check_method' => true, - /* - * Fire role and permission assignment events when listeners are registered. - */ 'events_enabled' => false, /* - * Scope roles and assignments by the configured team foreign key. + |-------------------------------------------------------------------------- + | Teams + |-------------------------------------------------------------------------- + | + | Teams scope roles and assignments by the configured team foreign key. + | A custom resolver must implement the PermissionsTeamResolver contract. + | */ + 'teams' => false, - /* - * Resolve the current team id. - */ 'team_resolver' => DefaultTeamResolver::class, /* @@ -71,33 +105,54 @@ 'use_passport_client_credentials' => false, /* - * Include required permission names in exception messages. + |-------------------------------------------------------------------------- + | Exception Messages + |-------------------------------------------------------------------------- + | + | These options expose required role or permission names in authorization + | exception messages. Leave them disabled when those names are sensitive. + | */ + 'display_permission_in_exception' => false, - /* - * Include required role names in exception messages. - */ 'display_role_in_exception' => false, /* - * Enable wildcard permission matching. + |-------------------------------------------------------------------------- + | Wildcard Permissions + |-------------------------------------------------------------------------- + | + | Wildcard matching is disabled by default. A custom parser must implement + | the Hypervel\Permission\Contracts\Wildcard contract. + | */ + 'enable_wildcard_permission' => false, + // 'wildcard_permission' => Hypervel\Permission\WildcardPermission::class, + /* - * The class used to parse wildcard permissions. - */ - 'wildcard_permission' => WildcardPermission::class, + |-------------------------------------------------------------------------- + | Permission Cache + |-------------------------------------------------------------------------- + | + | Permission data is cached for 24 hours by default. The named cache keys + | separate catalog and assignment data so each can be invalidated precisely. + | Omitted key members use the package names shown below. + | Column exclusions reduce the serialized catalog without hiding required + | model, partition, or team columns. + | + */ 'cache' => [ 'expiration_seconds' => 86400, 'store' => env('PERMISSION_CACHE_STORE', 'default'), 'keys' => [ - 'roles' => 'hypervel.permission.cache.roles', - 'model_roles' => 'hypervel.permission.cache.model.roles', - 'model_permissions' => 'hypervel.permission.cache.model.permissions', - 'model_token' => 'hypervel.permission.cache.model.token', + 'roles' => 'hypervel.permission.cache.roles', // Role and permission catalog. + 'model_roles' => 'hypervel.permission.cache.model.roles', // Per-model role assignments. + 'model_permissions' => 'hypervel.permission.cache.model.permissions', // Per-model direct permissions. + 'model_token' => 'hypervel.permission.cache.model.token', // Assignment-version tokens. ], 'column_names_except' => ['created_at', 'updated_at', 'deleted_at'], ], diff --git a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php index f8a5ebc17..aa9ece1ed 100644 --- a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php +++ b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php @@ -4,6 +4,7 @@ use Hypervel\Database\Migrations\Migration; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Permission\PermissionRegistrar; use Hypervel\Support\Facades\Schema; return new class extends Migration { @@ -12,15 +13,19 @@ */ public function up(): void { - $teams = (bool) config('permission.teams'); - $tableNames = (array) config('permission.table_names'); - $columnNames = (array) config('permission.column_names'); - $pivotRole = $columnNames['role_pivot_key'] ?? 'role_id'; - $pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id'; - $teamForeignKey = $columnNames['team_foreign_key'] ?? 'team_id'; - $modelMorphKey = $columnNames['model_morph_key'] ?? 'model_id'; - - throw_if($tableNames === [], 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.'); + $tableNames = config()->get('permission.table_names'); + + throw_if(! is_array($tableNames) || $tableNames === [], 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.'); + + $teams = config()->boolean('permission.teams'); + $columnNames = config()->array('permission.column_names'); + $pivotRole = $columnNames['role_pivot_key'] ?? PermissionRegistrar::DEFAULT_ROLE_PIVOT_KEY; + $pivotPermission = $columnNames['permission_pivot_key'] ?? PermissionRegistrar::DEFAULT_PERMISSION_PIVOT_KEY; + $teamForeignKey = array_key_exists('team_foreign_key', $columnNames) + ? $columnNames['team_foreign_key'] + : PermissionRegistrar::DEFAULT_TEAM_FOREIGN_KEY; + $modelMorphKey = $columnNames['model_morph_key']; + throw_if($teams && $teamForeignKey === '', 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.'); Schema::create($tableNames['permissions'], static function (Blueprint $table): void { @@ -112,9 +117,11 @@ public function up(): void $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); }); + $cacheStore = config()->string('permission.cache.store', 'default'); + app('cache') - ->store(config('permission.cache.store') !== 'default' ? config('permission.cache.store') : null) - ->forget(config('permission.cache.keys.roles')); + ->store($cacheStore !== 'default' ? $cacheStore : null) + ->forget(config()->string('permission.cache.keys.roles', PermissionRegistrar::ROLE_CATALOG_CACHE_KEY)); } /** @@ -122,9 +129,9 @@ public function up(): void */ public function down(): void { - $tableNames = (array) config('permission.table_names'); + $tableNames = config()->get('permission.table_names'); - throw_if($tableNames === [], 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + throw_if(! is_array($tableNames) || $tableNames === [], 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); Schema::dropIfExists($tableNames['role_has_permissions']); Schema::dropIfExists($tableNames['model_has_roles']); diff --git a/src/permission/database/migrations/add_teams_fields.php.stub b/src/permission/database/migrations/add_teams_fields.php.stub index 684ff5a46..ea3c4f8b2 100644 --- a/src/permission/database/migrations/add_teams_fields.php.stub +++ b/src/permission/database/migrations/add_teams_fields.php.stub @@ -4,6 +4,7 @@ declare(strict_types=1); use Hypervel\Database\Migrations\Migration; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Permission\PermissionRegistrar; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; @@ -13,13 +14,15 @@ return new class extends Migration { */ public function up(): void { - $teams = (bool) config('permission.teams'); - $tableNames = (array) config('permission.table_names'); - $columnNames = (array) config('permission.column_names'); - $pivotRole = $columnNames['role_pivot_key'] ?? 'role_id'; - $pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id'; - $teamForeignKey = $columnNames['team_foreign_key'] ?? 'team_id'; - $modelMorphKey = $columnNames['model_morph_key'] ?? 'model_id'; + $teams = config()->boolean('permission.teams'); + $tableNames = config()->array('permission.table_names'); + $columnNames = config()->array('permission.column_names'); + $pivotRole = $columnNames['role_pivot_key'] ?? PermissionRegistrar::DEFAULT_ROLE_PIVOT_KEY; + $pivotPermission = $columnNames['permission_pivot_key'] ?? PermissionRegistrar::DEFAULT_PERMISSION_PIVOT_KEY; + $teamForeignKey = array_key_exists('team_foreign_key', $columnNames) + ? $columnNames['team_foreign_key'] + : PermissionRegistrar::DEFAULT_TEAM_FOREIGN_KEY; + $modelMorphKey = $columnNames['model_morph_key']; if (! $teams) { return; @@ -82,13 +85,17 @@ return new class extends Migration { }); } + $cacheStore = config()->string('permission.cache.store', 'default'); + app('cache') - ->store(config('permission.cache.store') !== 'default' ? config('permission.cache.store') : null) - ->forget(config('permission.cache.keys.roles')); + ->store($cacheStore !== 'default' ? $cacheStore : null) + ->forget(config()->string('permission.cache.keys.roles', PermissionRegistrar::ROLE_CATALOG_CACHE_KEY)); } /** * Reverse the migrations. */ - public function down(): void {} + public function down(): void + { + } }; diff --git a/src/permission/src/Commands/UpgradeForTeamsCommand.php b/src/permission/src/Commands/UpgradeForTeamsCommand.php index 87e77dd30..6a4fa4dfd 100644 --- a/src/permission/src/Commands/UpgradeForTeamsCommand.php +++ b/src/permission/src/Commands/UpgradeForTeamsCommand.php @@ -23,7 +23,7 @@ class UpgradeForTeamsCommand extends Command */ public function handle(ConfigRepository $config): int { - if (! $config->boolean('permission.teams', false)) { + if (! $config->boolean('permission.teams')) { $this->error('Teams feature is disabled in your permission.php file.'); $this->warn('Please enable the teams setting in your configuration.'); diff --git a/src/permission/src/Guard.php b/src/permission/src/Guard.php index 51af214b5..bdabb7552 100644 --- a/src/permission/src/Guard.php +++ b/src/permission/src/Guard.php @@ -136,7 +136,7 @@ public static function normalizeName(?string $guard): ?string */ protected static function guards(): array { - return self::config()->array('auth.guards', []); + return self::config()->array('auth.guards'); } /** diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php index 298e6ff48..9c74fa54e 100644 --- a/src/permission/src/PermissionRegistrar.php +++ b/src/permission/src/PermissionRegistrar.php @@ -42,6 +42,16 @@ class PermissionRegistrar { + public const string DEFAULT_ROLE_PIVOT_KEY = 'role_id'; + + public const string DEFAULT_PERMISSION_PIVOT_KEY = 'permission_id'; + + public const string DEFAULT_TEAM_FOREIGN_KEY = 'team_id'; + + public const array DEFAULT_CACHE_COLUMN_NAMES_EXCEPT = ['created_at', 'updated_at', 'deleted_at']; + + public const string ROLE_CATALOG_CACHE_KEY = 'hypervel.permission.cache.roles'; + public const string MODEL_ROLES_CACHE_KEY_PREFIX = 'hypervel.permission.cache.model.roles'; public const string MODEL_PERMISSIONS_CACHE_KEY_PREFIX = 'hypervel.permission.cache.model.permissions'; @@ -235,9 +245,9 @@ public function ensureModelMatchesPartition(Model $model, PermissionPartition $p public function initializeCache(): void { /** @var class-string $permissionClass */ - $permissionClass = $this->config->string('permission.models.permission', Permission::class); + $permissionClass = $this->config->string('permission.models.permission'); /** @var class-string $roleClass */ - $roleClass = $this->config->string('permission.models.role', Role::class); + $roleClass = $this->config->string('permission.models.role'); /** @var null|class-string $teamClass */ $teamClass = $this->config->get('permission.models.team'); /** @var class-string $teamResolverClass */ @@ -249,18 +259,35 @@ public function initializeCache(): void $this->teamResolver = $this->app->make($teamResolverClass); $this->cacheExpirationTime = $this->config->integer('permission.cache.expiration_seconds', 86400); - $this->teams = $this->config->boolean('permission.teams', false); - $this->teamsKey = $this->config->string('permission.column_names.team_foreign_key', 'team_id'); + $this->teams = $this->config->boolean('permission.teams'); + $this->teamsKey = $this->config->string( + 'permission.column_names.team_foreign_key', + self::DEFAULT_TEAM_FOREIGN_KEY, + ); - $this->cacheKey = $this->config->string('permission.cache.keys.roles', 'hypervel.permission.cache.roles'); - $this->modelRolesCacheKeyPrefix = $this->config->string('permission.cache.keys.model_roles', self::MODEL_ROLES_CACHE_KEY_PREFIX); - $this->modelPermissionsCacheKeyPrefix = $this->config->string('permission.cache.keys.model_permissions', self::MODEL_PERMISSIONS_CACHE_KEY_PREFIX); - $this->modelCacheTokenKey = $this->config->string('permission.cache.keys.model_token', self::MODEL_CACHE_TOKEN_KEY); + $this->cacheKey = $this->config->string('permission.cache.keys.roles', self::ROLE_CATALOG_CACHE_KEY); + $this->modelRolesCacheKeyPrefix = $this->config->string( + 'permission.cache.keys.model_roles', + self::MODEL_ROLES_CACHE_KEY_PREFIX, + ); + $this->modelPermissionsCacheKeyPrefix = $this->config->string( + 'permission.cache.keys.model_permissions', + self::MODEL_PERMISSIONS_CACHE_KEY_PREFIX, + ); + $this->modelCacheTokenKey = $this->config->string( + 'permission.cache.keys.model_token', + self::MODEL_CACHE_TOKEN_KEY, + ); - $pivotRole = $this->config->get('permission.column_names.role_pivot_key'); - $pivotPermission = $this->config->get('permission.column_names.permission_pivot_key'); - $this->pivotRole = is_string($pivotRole) && $pivotRole !== '' ? $pivotRole : 'role_id'; - $this->pivotPermission = is_string($pivotPermission) && $pivotPermission !== '' ? $pivotPermission : 'permission_id'; + $columnNames = $this->config->array('permission.column_names'); + $pivotRole = $columnNames['role_pivot_key'] ?? self::DEFAULT_ROLE_PIVOT_KEY; + $pivotPermission = $columnNames['permission_pivot_key'] ?? self::DEFAULT_PERMISSION_PIVOT_KEY; + $this->pivotRole = is_string($pivotRole) && $pivotRole !== '' + ? $pivotRole + : self::DEFAULT_ROLE_PIVOT_KEY; + $this->pivotPermission = is_string($pivotPermission) && $pivotPermission !== '' + ? $pivotPermission + : self::DEFAULT_PERMISSION_PIVOT_KEY; $cacheStore = $this->config->string('permission.cache.store', 'default'); $this->cacheStoreName = $cacheStore === 'default' ? null : $cacheStore; @@ -285,7 +312,10 @@ protected function validateModelClasses(): void */ protected function validateCacheColumnExclusions(): void { - $except = $this->config->array('permission.cache.column_names_except', ['created_at', 'updated_at', 'deleted_at']); + $except = $this->config->array( + 'permission.cache.column_names_except', + self::DEFAULT_CACHE_COLUMN_NAMES_EXCEPT, + ); $partitionColumn = static::partitionColumn(); $roleColumns = [(new $this->roleClass)->getKeyName(), 'name', 'guard_name']; $permissionColumns = [(new $this->permissionClass)->getKeyName(), 'name', 'guard_name']; @@ -1399,7 +1429,10 @@ protected function getRolesForCache(): Collection */ private function getSerializedPermissionsForCache(): array { - $except = $this->config->array('permission.cache.column_names_except', ['created_at', 'updated_at', 'deleted_at']); + $except = $this->config->array( + 'permission.cache.column_names_except', + self::DEFAULT_CACHE_COLUMN_NAMES_EXCEPT, + ); $hasDeniedRolePermissions = false; $partition = $this->resolvePartition(); diff --git a/src/permission/src/PermissionServiceProvider.php b/src/permission/src/PermissionServiceProvider.php index d16089611..31632d41a 100644 --- a/src/permission/src/PermissionServiceProvider.php +++ b/src/permission/src/PermissionServiceProvider.php @@ -214,18 +214,16 @@ protected function registerGateHook(): void */ protected function registerAbout(): void { - $features = [ - 'Teams' => 'teams', - 'Wildcard Permissions' => 'enable_wildcard_permission', - 'Passport Client Credentials' => 'use_passport_client_credentials', - 'Denied Permissions' => null, - ]; - $config = $this->app->make('config'); - AboutCommand::add('Hypervel Permissions', static function () use ($features, $config): array { - $enabledFeatures = Collection::make($features) - ->filter(fn (?string $feature): bool => $feature === null || $config->boolean("permission.{$feature}", false)) + AboutCommand::add('Hypervel Permissions', static function () use ($config): array { + $enabledFeatures = Collection::make([ + 'Teams' => $config->boolean('permission.teams'), + 'Wildcard Permissions' => $config->boolean('permission.enable_wildcard_permission', false), + 'Passport Client Credentials' => $config->boolean('permission.use_passport_client_credentials', false), + 'Denied Permissions' => true, + ]) + ->filter() ->keys(); if (PermissionRegistrar::partitioningEnabled()) { diff --git a/src/permission/src/Support/Config.php b/src/permission/src/Support/Config.php index fbea79318..b584a4fad 100644 --- a/src/permission/src/Support/Config.php +++ b/src/permission/src/Support/Config.php @@ -12,6 +12,7 @@ use Hypervel\Permission\Exceptions\TeamModelNotConfigured; use Hypervel\Permission\Exceptions\TeamsNotEnabled; use Hypervel\Permission\PermissionRegistrar; +use Hypervel\Permission\WildcardPermission; class Config { @@ -165,7 +166,7 @@ public static function permissionModel(): string */ public static function eventsEnabled(): bool { - return self::repository()->boolean('permission.events_enabled'); + return self::repository()->boolean('permission.events_enabled', false); } /** @@ -173,7 +174,7 @@ public static function eventsEnabled(): bool */ public static function usePassportClientCredentials(): bool { - return self::repository()->boolean('permission.use_passport_client_credentials'); + return self::repository()->boolean('permission.use_passport_client_credentials', false); } /** @@ -181,7 +182,7 @@ public static function usePassportClientCredentials(): bool */ public static function displayRoleInException(): bool { - return self::repository()->boolean('permission.display_role_in_exception'); + return self::repository()->boolean('permission.display_role_in_exception', false); } /** @@ -189,7 +190,7 @@ public static function displayRoleInException(): bool */ public static function displayPermissionInException(): bool { - return self::repository()->boolean('permission.display_permission_in_exception'); + return self::repository()->boolean('permission.display_permission_in_exception', false); } /** @@ -197,7 +198,7 @@ public static function displayPermissionInException(): bool */ public static function wildcardPermissionsEnabled(): bool { - return self::repository()->boolean('permission.enable_wildcard_permission'); + return self::repository()->boolean('permission.enable_wildcard_permission', false); } /** @@ -205,6 +206,6 @@ public static function wildcardPermissionsEnabled(): bool */ public static function wildcardPermissionClass(): string { - return self::repository()->string('permission.wildcard_permission'); + return self::repository()->string('permission.wildcard_permission', WildcardPermission::class); } } diff --git a/src/queue/src/BackgroundQueue.php b/src/queue/src/BackgroundQueue.php index db95f1d5d..9af523139 100644 --- a/src/queue/src/BackgroundQueue.php +++ b/src/queue/src/BackgroundQueue.php @@ -47,8 +47,7 @@ public function later(DateInterval|DateTimeInterface|int $delay, object|string $ /** @var DatabaseTransactionsManager $transactions */ $transactions = $this->container->make('db.transactions'); - $this->addUniqueJobRollbackCallback($transactions, $job); - $this->addDebouncedJobRollbackCallback($transactions, $job); + $this->addJobRollbackCallback($transactions, $job); $transactions->addCallback( fn () => $this->scheduleTimer( diff --git a/src/queue/src/BeanstalkdQueue.php b/src/queue/src/BeanstalkdQueue.php index 8bf1b074e..54d0ca5bb 100644 --- a/src/queue/src/BeanstalkdQueue.php +++ b/src/queue/src/BeanstalkdQueue.php @@ -189,22 +189,6 @@ static function ( ); } - /** - * Push an array of jobs onto the queue. - */ - public function bulk(array $jobs, mixed $data = '', ?string $queue = null): mixed - { - foreach ((array) $jobs as $job) { - if (isset($job->delay)) { - $this->later($job->delay, $job, $data, $queue); - } else { - $this->push($job, $data, $queue); - } - } - - return null; - } - /** * Pop the next job off of the queue. */ diff --git a/src/queue/src/ClearableQueuePoolProxy.php b/src/queue/src/ClearableQueuePoolProxy.php new file mode 100644 index 000000000..bea84f628 --- /dev/null +++ b/src/queue/src/ClearableQueuePoolProxy.php @@ -0,0 +1,18 @@ +invoke(__FUNCTION__, func_get_args()); + } +} diff --git a/src/queue/src/Connectors/BackgroundConnector.php b/src/queue/src/Connectors/BackgroundConnector.php index 2c306c1d6..806b7d01e 100644 --- a/src/queue/src/Connectors/BackgroundConnector.php +++ b/src/queue/src/Connectors/BackgroundConnector.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\BackgroundQueue; +use Hypervel\Support\Arr; class BackgroundConnector implements ConnectorInterface { @@ -23,7 +24,7 @@ public function __construct( */ public function connect(array $config): Queue { - return (new BackgroundQueue($config['after_commit'] ?? false)) + return (new BackgroundQueue(Arr::get($config, 'after_commit', true))) ->setExceptionCallback($this->exceptionCallback); } } diff --git a/src/queue/src/Connectors/BeanstalkdConnector.php b/src/queue/src/Connectors/BeanstalkdConnector.php index bb07dc92b..3739c78ba 100644 --- a/src/queue/src/Connectors/BeanstalkdConnector.php +++ b/src/queue/src/Connectors/BeanstalkdConnector.php @@ -6,7 +6,7 @@ use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\BeanstalkdQueue; -use Pheanstalk\Contract\SocketFactoryInterface; +use Hypervel\Support\Arr; use Pheanstalk\Pheanstalk; use Pheanstalk\Values\Timeout; @@ -22,7 +22,7 @@ public function connect(array $config): Queue $config['queue'], $config['retry_after'] ?? Pheanstalk::DEFAULT_TTR, $config['block_for'] ?? 0, - $config['after_commit'] ?? false + Arr::get($config, 'after_commit', true) ); } @@ -31,10 +31,12 @@ public function connect(array $config): Queue */ protected function pheanstalk(array $config): Pheanstalk { + $timeout = $config['timeout'] ?? null; + return Pheanstalk::create( $config['host'], - $config['port'] ?? SocketFactoryInterface::DEFAULT_PORT, - isset($config['timeout']) ? new Timeout($config['timeout']) : null, + $config['port'], + $timeout === null ? null : new Timeout($timeout), ); } } diff --git a/src/queue/src/Connectors/DatabaseConnector.php b/src/queue/src/Connectors/DatabaseConnector.php index 02e70f4d7..e4824e0d1 100644 --- a/src/queue/src/Connectors/DatabaseConnector.php +++ b/src/queue/src/Connectors/DatabaseConnector.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Queue\Queue; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Queue\DatabaseQueue; +use Hypervel\Support\Arr; class DatabaseConnector implements ConnectorInterface { @@ -28,8 +29,8 @@ public function connect(array $config): Queue $config['connection'] ?? null, $config['table'], $config['queue'], - $config['retry_after'] ?? 60, - $config['after_commit'] ?? false + $config['retry_after'] ?? DatabaseQueue::DEFAULT_RETRY_AFTER, + Arr::get($config, 'after_commit', false) ); } } diff --git a/src/queue/src/Connectors/DeferredConnector.php b/src/queue/src/Connectors/DeferredConnector.php index a59e7d066..6d102c197 100644 --- a/src/queue/src/Connectors/DeferredConnector.php +++ b/src/queue/src/Connectors/DeferredConnector.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\DeferredQueue; +use Hypervel\Support\Arr; class DeferredConnector implements ConnectorInterface { @@ -23,7 +24,7 @@ public function __construct( */ public function connect(array $config): Queue { - return (new DeferredQueue($config['after_commit'] ?? false)) + return (new DeferredQueue(Arr::get($config, 'after_commit', true))) ->setExceptionCallback($this->exceptionCallback); } } diff --git a/src/queue/src/Connectors/FailoverConnector.php b/src/queue/src/Connectors/FailoverConnector.php index 1c3c2c97c..8593ab809 100644 --- a/src/queue/src/Connectors/FailoverConnector.php +++ b/src/queue/src/Connectors/FailoverConnector.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\FailoverQueue; use Hypervel\Queue\QueueManager; +use Hypervel\Support\Arr; class FailoverConnector implements ConnectorInterface { @@ -29,6 +30,7 @@ public function connect(array $config): Queue $this->manager, $this->events, $config['connections'], + Arr::get($config, 'after_commit', true), ); } } diff --git a/src/queue/src/Connectors/RedisConnector.php b/src/queue/src/Connectors/RedisConnector.php index 323ffaf10..4aac59792 100644 --- a/src/queue/src/Connectors/RedisConnector.php +++ b/src/queue/src/Connectors/RedisConnector.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Queue\Queue; use Hypervel\Contracts\Redis\Factory as Redis; use Hypervel\Queue\RedisQueue; +use Hypervel\Support\Arr; class RedisConnector implements ConnectorInterface { @@ -28,10 +29,10 @@ public function connect(array $config): Queue $this->redis, $config['queue'], $config['connection'] ?? $this->connection, - $config['retry_after'] ?? 60, + $config['retry_after'] ?? RedisQueue::DEFAULT_RETRY_AFTER, $config['block_for'] ?? null, - $config['after_commit'] ?? false, - $config['migration_batch_size'] ?? -1 + Arr::get($config, 'after_commit', true), + Arr::get($config, 'migration_batch_size', RedisQueue::DEFAULT_MIGRATION_BATCH_SIZE) ); } } diff --git a/src/queue/src/Connectors/SqsConnector.php b/src/queue/src/Connectors/SqsConnector.php index adde3c3b4..9fc197b31 100644 --- a/src/queue/src/Connectors/SqsConnector.php +++ b/src/queue/src/Connectors/SqsConnector.php @@ -20,25 +20,44 @@ public function connect(array $config): Queue { $config = $this->getDefaultConfiguration($config); - if ($credentials = $this->resolveCredentialProvider($config)) { - $config['credentials'] = $credentials; - } elseif (! empty($config['key']) && ! empty($config['secret'])) { - $config['credentials'] = Arr::only($config, ['key', 'secret']); + $key = $config['key']; + $secret = $config['secret']; + $token = $config['token']; + $credentials = $config['credentials']; - if (! empty($config['token'])) { - $config['credentials']['token'] = $config['token']; + if (($resolvedCredentials = $this->resolveCredentialProvider($config)) !== null) { + $config['credentials'] = $resolvedCredentials; + } elseif ($credentials === null && empty($key) !== empty($secret)) { + throw new InvalidArgumentException('The SQS access key and secret must be configured together.'); + } elseif ($credentials === null && ! empty($key) && ! empty($secret)) { + $config['credentials'] = ['key' => $key, 'secret' => $secret]; + + if (! empty($token)) { + $config['credentials']['token'] = $token; } } + // The queue token is an AWS session credential, while the SDK's + // top-level token option is an unrelated bearer token. + $clientConfig = Arr::except($config, [ + 'driver', + 'queue', + 'prefix', + 'suffix', + 'after_commit', + 'key', + 'secret', + 'token', + 'overflow', + ]); + return new SqsQueue( - new SqsClient( - Arr::except($config, ['token', 'overflow']) - ), + new SqsClient($clientConfig), $config['queue'], - $config['prefix'] ?? '', - $config['suffix'] ?? '', - $config['after_commit'] ?? false, - $config['overflow'] ?? [], + $config['prefix'], + $config['suffix'], + $config['after_commit'], + $config['overflow'], ); } @@ -49,7 +68,7 @@ public function connect(array $config): Queue */ protected function resolveCredentialProvider(array $config): mixed { - $credentials = $config['credentials'] ?? null; + $credentials = $config['credentials']; $provider = is_array($credentials) ? ($credentials['provider'] ?? null) : $credentials; @@ -75,12 +94,23 @@ protected function resolveCredentialProvider(array $config): mixed */ protected function getDefaultConfiguration(array $config): array { - return array_merge([ + return [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'credentials' => null, + 'after_commit' => true, + 'overflow' => [], 'version' => 'latest', + ...$config, + // Shipped env-backed values may be null, while SqsQueue requires strings. + 'prefix' => $config['prefix'] ?? '', + 'suffix' => $config['suffix'] ?? '', 'http' => [ 'timeout' => 60, 'connect_timeout' => 60, + ...($config['http'] ?? []), ], - ], $config); + ]; } } diff --git a/src/queue/src/Connectors/SyncConnector.php b/src/queue/src/Connectors/SyncConnector.php index 16ad26467..ccb70d27f 100644 --- a/src/queue/src/Connectors/SyncConnector.php +++ b/src/queue/src/Connectors/SyncConnector.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\SyncQueue; +use Hypervel\Support\Arr; class SyncConnector implements ConnectorInterface { @@ -14,6 +15,6 @@ class SyncConnector implements ConnectorInterface */ public function connect(array $config): Queue { - return new SyncQueue($config['after_commit'] ?? false); + return new SyncQueue(Arr::get($config, 'after_commit', false)); } } diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index c25c07ade..034b42f89 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -145,7 +145,7 @@ protected function runWorker(string $connection, string $queue): ?int protected function gatherWorkerOptions(): WorkerOptions { $concurrency = $this->option('concurrency') === null - ? max(1, $this->config->integer('queue.concurrency_number')) + ? max(1, $this->config->integer('queue.concurrency')) : max(1, (int) $this->option('concurrency')); return new WorkerOptions( diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index e3ba2b320..e33a7d0b1 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -13,7 +13,6 @@ use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\Query\Builder; -use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Jobs\DatabaseJob; use Hypervel\Queue\Jobs\DatabaseJobRecord; use Hypervel\Queue\Jobs\InspectedJob; @@ -25,6 +24,8 @@ class DatabaseQueue extends Queue implements QueueContract, ClearableQueue { + public const int DEFAULT_RETRY_AFTER = 60; + /** * Create a new database queue instance. * @@ -39,7 +40,7 @@ public function __construct( protected ?string $connection, protected string $table, protected string $default = 'default', - protected ?int $retryAfter = 60, + protected int $retryAfter = self::DEFAULT_RETRY_AFTER, protected bool $dispatchAfterCommit = false ) { } @@ -301,8 +302,7 @@ public function bulk(array $jobs, mixed $data = '', ?string $queue = null): mixe // A non-empty deferred group means partitionJobsByAfterCommit() resolved a transactions manager. foreach ($afterCommit as $job) { /** @var DatabaseTransactionsManager $transactions */ - $this->addUniqueJobRollbackCallback($transactions, $job); - $this->addDebouncedJobRollbackCallback($transactions, $job); + $this->addJobRollbackCallback($transactions, $job); } if ($this->afterCommitDispatcher !== null) { @@ -336,9 +336,7 @@ protected function prepareBatchJobs(array $jobs, mixed $data, ?string $queue): a { return Collection::make($jobs) ->map(function (object|string $job) use ($data, $queue): array { - $delay = is_object($job) - ? $this->getAttributeValue($job, Delay::class, 'delay') - : null; + $delay = $this->getJobDelay($job); return [ 'job' => $job, diff --git a/src/queue/src/DeferredQueue.php b/src/queue/src/DeferredQueue.php index eaa9f7b63..9406864d8 100644 --- a/src/queue/src/DeferredQueue.php +++ b/src/queue/src/DeferredQueue.php @@ -47,8 +47,7 @@ public function later(DateInterval|DateTimeInterface|int $delay, object|string $ /** @var DatabaseTransactionsManager $transactions */ $transactions = $this->container->make('db.transactions'); - $this->addUniqueJobRollbackCallback($transactions, $job); - $this->addDebouncedJobRollbackCallback($transactions, $job); + $this->addJobRollbackCallback($transactions, $job); $transactions->addCallback( fn () => $this->scheduleTimer( diff --git a/src/queue/src/FailoverQueue.php b/src/queue/src/FailoverQueue.php index 0ba636224..ef5982d45 100644 --- a/src/queue/src/FailoverQueue.php +++ b/src/queue/src/FailoverQueue.php @@ -8,14 +8,17 @@ use DateTimeInterface; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Contracts\Queue\IndexAwareQueue; use Hypervel\Contracts\Queue\Job as JobContract; use Hypervel\Contracts\Queue\Queue as QueueContract; +use Hypervel\Database\DatabaseTransactionRecord; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Queue\Events\QueueFailedOver; use Hypervel\Support\Collection; use RuntimeException; use Throwable; -class FailoverQueue extends Queue implements QueueContract +class FailoverQueue extends Queue implements QueueContract, IndexAwareQueue { /** * Context key prefix for the queues which failed on the last action. @@ -35,7 +38,8 @@ class FailoverQueue extends Queue implements QueueContract public function __construct( public QueueManager $manager, public Dispatcher $events, - public array $connections + public array $connections, + protected bool $dispatchAfterCommit = false ) { } @@ -157,9 +161,13 @@ public function later(DateInterval|DateTimeInterface|int $delay, object|string $ /** * Pop the next job off of the queue. */ - public function pop(?string $queue = null): ?JobContract + public function pop(?string $queue = null, int $index = 0): ?JobContract { - return $this->manager->connection($this->connections[0])->pop($queue); + $connection = $this->manager->connection($this->connections[0]); + + return $connection instanceof IndexAwareQueue + ? $connection->pop($queue, $index) + : $connection->pop($queue); } /** @@ -169,6 +177,19 @@ public function pop(?string $queue = null): ?JobContract */ protected function attemptOnAllConnections(string $method, array $arguments, object|string|null $job = null): mixed { + if ( + $job !== null + && $this->shouldDispatchAfterCommit($job) + && $this->container->has('db.transactions') + ) { + /** @var DatabaseTransactionsManager $transactions */ + $transactions = $this->container->make('db.transactions'); + + if ($this->deferUntilAllTransactionsCommit($transactions, $method, $arguments, $job)) { + return null; + } + } + $contextKey = self::FAILING_QUEUES_CONTEXT_PREFIX . spl_object_id($this); $failingQueues = CoroutineContext::get($contextKey, []); @@ -194,4 +215,65 @@ protected function attemptOnAllConnections(string $method, array $arguments, obj throw $lastException ?? new RuntimeException('All failover queue connections failed.'); } + + /** + * Defer the failover attempt until every applicable transaction commits. + */ + protected function deferUntilAllTransactionsCommit( + DatabaseTransactionsManager $transactions, + string $method, + array $arguments, + object|string $job + ): bool { + $connections = $transactions->callbackApplicableTransactions() + ->map(static fn (DatabaseTransactionRecord $transaction): string => $transaction->connection) + ->uniqueStrict() + ->values() + ->all(); + + if ($connections === []) { + return false; + } + + $pendingConnections = array_fill_keys($connections, true); + $settled = false; + $releaseLocks = $this->createJobRollbackCallback($job); + + // Cancellation must be ready before addCallback(), which may execute inline. + foreach ($connections as $connection) { + $transactions->addCallbackForRollback( + static function () use (&$settled, $releaseLocks): void { + if ($settled) { + return; + } + + $settled = true; + $releaseLocks?->__invoke(); + }, + $connection + ); + } + + foreach ($connections as $connection) { + $transactions->addCallback( + function () use (&$pendingConnections, &$settled, $connection, $method, $arguments, $job): void { + if ($settled) { + return; + } + + unset($pendingConnections[$connection]); + + if ($pendingConnections !== []) { + return; + } + + $settled = true; + $this->attemptOnAllConnections($method, $arguments, $job); + }, + $connection + ); + } + + return true; + } } diff --git a/src/queue/src/Jobs/Job.php b/src/queue/src/Jobs/Job.php index 38a15251e..0d87e3df5 100644 --- a/src/queue/src/Jobs/Job.php +++ b/src/queue/src/Jobs/Job.php @@ -269,9 +269,10 @@ public function fail(?Throwable $e = null): void if ($this->shouldRollBackDatabaseTransaction($e)) { $config = $this->container->make('config'); + $failed = $config->array('queue.failed'); $this->container->make('db') - ->connection($config->string('queue.failed.database')) + ->connection($failed['database']) ->rollBack(toLevel: 0); } @@ -304,9 +305,9 @@ protected function shouldRollBackDatabaseTransaction(?Throwable $e): bool } $config = $this->container->make('config'); + $failed = $config->array('queue.failed'); - return $config->get('queue.failed.database') - && in_array($config->get('queue.failed.driver'), ['database', 'database-uuids'], true) + return in_array($failed['driver'], ['database', 'database-uuids'], true) && $this->container->bound('db'); } diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 86f4efa20..f453289c2 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -19,6 +19,7 @@ use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Queue\Attributes\Backoff; +use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\DeleteWhenMissingModels; use Hypervel\Queue\Attributes\FailOnTimeout; use Hypervel\Queue\Attributes\MaxExceptions; @@ -101,13 +102,30 @@ public function laterOn(?string $queue, DateInterval|DateTimeInterface|int $dela public function bulk(array $jobs, mixed $data = '', ?string $queue = null): mixed { foreach ((array) $jobs as $job) { - /* @phpstan-ignore-next-line */ - $this->push($job, $data, $queue); + $delay = $this->getJobDelay($job); + + if ($delay !== null) { + /* @phpstan-ignore-next-line */ + $this->later($delay, $job, $data, $queue); + } else { + /* @phpstan-ignore-next-line */ + $this->push($job, $data, $queue); + } } return null; } + /** + * Get the delay configured on the given job. + */ + protected function getJobDelay(object|string $job): mixed + { + return is_object($job) + ? $this->getAttributeValue($job, Delay::class, 'delay') + : null; + } + /** * Create a payload string from the given job and data. * @@ -357,8 +375,7 @@ protected function enqueueUsing(object|string $job, string $payload, ?string $qu $transactions = $this->container->make('db.transactions'); if ($transactions->callbackApplicableTransactions()->isNotEmpty()) { - $this->addUniqueJobRollbackCallback($transactions, $job); - $this->addDebouncedJobRollbackCallback($transactions, $job); + $this->addJobRollbackCallback($transactions, $job); if ($this->afterCommitDispatcher !== null) { $dispatcher = $this->afterCommitDispatcher; @@ -441,35 +458,55 @@ protected function shouldDispatchAfterCommit(object|string $job): bool } /** - * Register a transaction rollback callback that releases the unique lock for the given job. + * Register a transaction rollback callback that releases the job's locks. */ - protected function addUniqueJobRollbackCallback(DatabaseTransactionsManager $transactions, object|string $job): void + protected function addJobRollbackCallback(DatabaseTransactionsManager $transactions, object|string $job): void { - if (! $job instanceof ShouldBeUnique) { - return; - } - - $lock = new UniqueLock($this->container->make(Cache::class)); + $callback = $this->createJobRollbackCallback($job); - $transactions->addCallbackForRollback( - static fn () => $lock->release($job) - ); + if ($callback !== null) { + $transactions->addCallbackForRollback($callback); + } } /** - * Register a transaction rollback callback that releases the debounce token for the given job. + * Create a callback that releases the job's locks. */ - protected function addDebouncedJobRollbackCallback(DatabaseTransactionsManager $transactions, object|string $job): void + protected function createJobRollbackCallback(object|string $job): ?Closure { - if (! is_object($job) || ($job->debounceOwner ?? '') === '') { - return; + $uniqueLock = $job instanceof ShouldBeUnique + ? new UniqueLock($this->container->make(Cache::class)) + : null; + $debounceOwner = is_object($job) ? ($job->debounceOwner ?? '') : ''; + $debounceLock = $debounceOwner !== '' + ? new DebounceLock($this->container->make(Cache::class)) + : null; + + if ($uniqueLock === null && $debounceLock === null) { + return null; } - $lock = new DebounceLock($this->container->make(Cache::class)); + return static function () use ($uniqueLock, $debounceLock, $debounceOwner, $job): void { + // Both locks share one transaction callback, so preserve the transaction + // record's exception isolation between releases. + $exception = null; - $transactions->addCallbackForRollback( - static fn () => $lock->release($job, $job->debounceOwner ?? '') - ); + try { + $uniqueLock?->release($job); + } catch (Throwable $throwable) { + $exception = $throwable; + } + + try { + $debounceLock?->release($job, $debounceOwner); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if ($exception !== null) { + throw $exception; + } + }; } /** diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index 201c57046..4fb4c0c29 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -44,6 +44,21 @@ class QueueManager implements FactoryContract, MonitorContract */ protected array $poolables = ['beanstalkd', 'sqs']; + /** + * The pool proxy classes for drivers with supplemental queue capabilities. + * + * Proxy selection occurs by driver name before lazy resolution. A subclass must + * update this map for a custom clearable driver or a non-clearable replacement + * of a mapped built-in driver. + * + * @var array> + */ + protected array $poolProxyClasses = [ + 'database' => ClearableQueuePoolProxy::class, + 'redis' => ClearableQueuePoolProxy::class, + 'sqs' => ClearableQueuePoolProxy::class, + ]; + /** * Create a new queue manager instance. */ @@ -299,7 +314,7 @@ protected function resolve(string $name): Queue $config['driver'], $resolver, $this->poolDefinition($config['driver'], $config['pool'] ?? [], $constructionConfig), - QueuePoolProxy::class, + $this->poolProxyClasses[$config['driver']] ?? QueuePoolProxy::class, ); return $proxy->setConnectionName($name); diff --git a/src/queue/src/QueuePoolProxy.php b/src/queue/src/QueuePoolProxy.php index 0d79b261c..4411690da 100644 --- a/src/queue/src/QueuePoolProxy.php +++ b/src/queue/src/QueuePoolProxy.php @@ -7,6 +7,7 @@ use Closure; use DateInterval; use DateTimeInterface; +use Hypervel\Contracts\Queue\IndexAwareQueue; use Hypervel\Contracts\Queue\Job; use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\ObjectPool\Contracts\Factory; @@ -18,7 +19,7 @@ use RuntimeException; use Throwable; -class QueuePoolProxy extends PoolProxy implements QueueContract +class QueuePoolProxy extends PoolProxy implements QueueContract, IndexAwareQueue { /** * The logical connection name applied to each borrowed queue. @@ -206,14 +207,16 @@ public function withConnection(Closure $callback): mixed /** * Pop the next job off of the queue. */ - public function pop(?string $queue = null): ?Job + public function pop(?string $queue = null, int $index = 0): ?Job { $lease = $this->lease(); try { /** @var QueueContract $connection */ $connection = $lease->get(); - $job = $connection->pop($queue); + $job = $connection instanceof IndexAwareQueue + ? $connection->pop($queue, $index) + : $connection->pop($queue); if ($job === null) { $lease->release(); diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php index a6cf3451d..9f653463d 100644 --- a/src/queue/src/RedisQueue.php +++ b/src/queue/src/RedisQueue.php @@ -7,10 +7,10 @@ use DateInterval; use DateTimeInterface; use Hypervel\Contracts\Queue\ClearableQueue; +use Hypervel\Contracts\Queue\IndexAwareQueue; use Hypervel\Contracts\Queue\Job as JobContract; use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Contracts\Redis\Factory as Redis; -use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Jobs\InspectedJob; use Hypervel\Queue\Jobs\RedisJob; use Hypervel\Redis\RedisConnection; @@ -18,8 +18,12 @@ use Hypervel\Support\Collection; use Hypervel\Support\Str; -class RedisQueue extends Queue implements QueueContract, ClearableQueue +class RedisQueue extends Queue implements QueueContract, ClearableQueue, IndexAwareQueue { + public const int DEFAULT_RETRY_AFTER = 60; + + public const int DEFAULT_MIGRATION_BATCH_SIZE = -1; + /** * Indicates if a secondary queue had a job available between checks of the primary queue. * @@ -46,10 +50,10 @@ public function __construct( protected Redis $redis, protected string $default = 'default', protected ?string $connection = null, - protected ?int $retryAfter = 60, + protected ?int $retryAfter = self::DEFAULT_RETRY_AFTER, protected ?int $blockFor = null, protected bool $dispatchAfterCommit = false, - protected int $migrationBatchSize = -1 + protected int $migrationBatchSize = self::DEFAULT_MIGRATION_BATCH_SIZE ) { } @@ -251,9 +255,7 @@ public function bulk(array $jobs, mixed $data = '', ?string $queue = null): mixe $callback = function () use ($jobs, $data, $queue): void { foreach ($jobs as $job) { - $delay = is_object($job) - ? $this->getAttributeValue($job, Delay::class, 'delay') - : null; + $delay = $this->getJobDelay($job); if ($delay !== null) { $this->later($delay, $job, $data, $queue); diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php index 088e09cc6..8af0965d7 100644 --- a/src/queue/src/SqsQueue.php +++ b/src/queue/src/SqsQueue.php @@ -288,8 +288,7 @@ public function bulk(array $jobs, mixed $data = '', ?string $queue = null): mixe // A non-empty deferred group means partitionJobsByAfterCommit() resolved a transactions manager. foreach ($afterCommit as $job) { /** @var DatabaseTransactionsManager $transactions */ - $this->addUniqueJobRollbackCallback($transactions, $job); - $this->addDebouncedJobRollbackCallback($transactions, $job); + $this->addJobRollbackCallback($transactions, $job); } if ($this->afterCommitDispatcher !== null) { diff --git a/src/queue/src/SyncQueue.php b/src/queue/src/SyncQueue.php index 46dbfdc97..30232445b 100644 --- a/src/queue/src/SyncQueue.php +++ b/src/queue/src/SyncQueue.php @@ -128,8 +128,7 @@ public function push(object|string $job, mixed $data = '', ?string $queue = null /** @var DatabaseTransactionsManager $transactions */ $transactions = $this->container->make('db.transactions'); - $this->addUniqueJobRollbackCallback($transactions, $job); - $this->addDebouncedJobRollbackCallback($transactions, $job); + $this->addJobRollbackCallback($transactions, $job); $transactions->addCallback( fn () => $this->executeJob($job, $data, $queue) diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index 52c2e1fac..1a5aadd3b 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Queue\Factory as QueueManager; +use Hypervel\Contracts\Queue\IndexAwareQueue; use Hypervel\Contracts\Queue\Interruptible; use Hypervel\Contracts\Queue\Job as JobContract; use Hypervel\Contracts\Queue\Queue as QueueContract; @@ -534,8 +535,9 @@ public function runNextJob(string $connectionName, string $queue, WorkerOptions protected function getNextJob(QueueContract $connection, string $queue): ?JobContract { $popJobCallback = function ($queue, $index = 0) use ($connection) { - /** @var RedisQueue $connection */ - return $connection->pop($queue, $index); + return $connection instanceof IndexAwareQueue + ? $connection->pop($queue, $index) + : $connection->pop($queue); }; $this->raiseBeforeJobPopEvent($connection->getConnectionName(), $queue); diff --git a/src/rate-limiter/src/RateLimiter.php b/src/rate-limiter/src/RateLimiter.php index 0ac38a0b3..37f92e0a5 100644 --- a/src/rate-limiter/src/RateLimiter.php +++ b/src/rate-limiter/src/RateLimiter.php @@ -206,7 +206,7 @@ protected function createWorkerArrayDriver(): Store protected function createDatabaseDriver(array $config): Store { $connection = $config['connection'] ?? null; - $table = $config['table'] ?? null; + $table = $config['table']; if ($connection !== null && (! is_string($connection) || $connection === '')) { throw new InvalidArgumentException('The rate limiter database connection must be null or a non-empty string.'); @@ -228,7 +228,7 @@ protected function createDatabaseDriver(array $config): Store */ protected function createRedisDriver(array $config): Store { - $connection = $config['connection'] ?? null; + $connection = $config['connection']; if (! is_string($connection) || $connection === '') { throw new InvalidArgumentException('The rate limiter Redis connection must be a non-empty string.'); @@ -246,7 +246,7 @@ protected function createRedisDriver(array $config): Store protected function createSwooleDriver(array $config): Store { $name = $config['name'] ?? null; - $memoryLimitBuffer = $config['memory_limit_buffer'] ?? null; + $memoryLimitBuffer = $config['memory_limit_buffer'] ?? 0.05; if (! is_string($name) || $name === '') { throw new InvalidArgumentException( diff --git a/src/redis/src/PhpRedisClusterConnection.php b/src/redis/src/PhpRedisClusterConnection.php index 171e2cb04..21e6691c9 100644 --- a/src/redis/src/PhpRedisClusterConnection.php +++ b/src/redis/src/PhpRedisClusterConnection.php @@ -52,7 +52,7 @@ public function reconnect(): bool $this->connection = $redis; $this->markReconnected(); - if (($this->config['events'] ?? false) && $this->container->bound('events')) { + if ($this->config['events'] && $this->container->bound('events')) { $this->eventDispatcher = $this->container->make('events'); } @@ -281,17 +281,17 @@ protected function createRedisCluster(): RedisCluster try { $parameters = [ null, - $this->config['cluster']['seeds'] ?? [], - $this->config['timeout'] ?? 0.0, - $this->config['read_timeout'] ?? 0.0, + $this->config['cluster']['seeds'], + $this->config['timeout'], + $this->config['read_timeout'], false, $this->formatClusterPassword(), ]; - if (($this->config['scheme'] ?? 'tcp') === 'tls') { + if ($this->config['scheme'] === 'tls') { // RedisCluster needs the context argument to carry TLS to endpoints discovered after bootstrapping. $parameters[] = $this->normalizeClusterContext( - $this->config['context'] ?? [] + $this->config['context'] ); } @@ -327,8 +327,8 @@ protected function normalizeClusterContext(array $context): array */ protected function formatClusterPassword(): mixed { - $password = $this->config['password'] ?? null; - $username = $this->config['username'] ?? null; + $password = $this->config['password']; + $username = $this->config['username']; return $username !== null && $username !== '' && is_string($password) ? [$username, $password] diff --git a/src/redis/src/PhpRedisConnection.php b/src/redis/src/PhpRedisConnection.php index 8f413bbf6..bfbd86960 100644 --- a/src/redis/src/PhpRedisConnection.php +++ b/src/redis/src/PhpRedisConnection.php @@ -46,9 +46,9 @@ public function reconnect(): bool $this->setOptions($redis); - $auth = $this->config['password'] ?? null; + $auth = $this->config['password']; if ($auth !== null && $auth !== '') { - $username = $this->config['username'] ?? null; + $username = $this->config['username']; $redis->auth( $username !== null && $username !== '' && is_string($auth) ? [$username, $auth] @@ -56,12 +56,12 @@ public function reconnect(): bool ); } - $database = $this->database ?? (int) ($this->config['database'] ?? 0); + $database = $this->database ?? $this->config['database']; if ($database > 0) { $redis->select($database); } - $name = $this->config['name'] ?? null; + $name = $this->config['name']; if ($name !== null && $name !== '') { $redis->client('SETNAME', $name); } @@ -69,7 +69,7 @@ public function reconnect(): bool $this->connection = $redis; $this->markReconnected(); - if (($this->config['events'] ?? false) && $this->container->bound('events')) { + if ($this->config['events'] && $this->container->bound('events')) { $this->eventDispatcher = $this->container->make('events'); } @@ -115,14 +115,14 @@ protected function createRedis(array $config): Redis { $parameters = [ $this->formatHost($config), - (int) $config['port'], - $config['timeout'] ?? 0.0, + $config['port'], + $config['timeout'], null, - $config['retry_interval'] ?? 0, - $config['read_timeout'] ?? 0.0, + 0, // Hypervel applies the complete retry policy through setOptions(). + $config['read_timeout'], ]; - if (! empty($config['context'])) { + if ($config['context'] !== []) { $parameters[] = $this->normalizeContext($config['context']); } @@ -198,13 +198,12 @@ protected function createRedisSentinel(): Redis ->resolveMaster($this->config); $redis = $this->createRedis([ - 'scheme' => $this->config['scheme'] ?? null, + 'scheme' => $this->config['scheme'], 'host' => $host, 'port' => $port, - 'timeout' => $this->config['timeout'] ?? 0, - 'retry_interval' => $this->config['retry_interval'] ?? 0, - 'read_timeout' => $this->config['read_timeout'] ?? 0, - 'context' => $this->config['context'] ?? [], + 'timeout' => $this->config['timeout'], + 'read_timeout' => $this->config['read_timeout'], + 'context' => $this->config['context'], ]); } catch (Throwable $exception) { throw new ConnectionException('Connection reconnect failed ' . $exception->getMessage()); diff --git a/src/redis/src/Pool/RedisPool.php b/src/redis/src/Pool/RedisPool.php index 6804f4388..458bed3e7 100644 --- a/src/redis/src/Pool/RedisPool.php +++ b/src/redis/src/Pool/RedisPool.php @@ -13,7 +13,6 @@ use Hypervel\Redis\PhpRedisConnection; use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisConnection; -use Hypervel\Support\Arr; use Throwable; class RedisPool extends Pool @@ -31,13 +30,13 @@ public function __construct(Container $container, string $name) { $configService = $container->make(RedisConfig::class); $this->config = $configService->connectionConfig($name); - $poolOptions = Arr::get($this->config, 'pool', []); + $poolOptions = $this->config['pool']; $this->frequency = new Frequency; parent::__construct($container, $name, $poolOptions); - if (! array_key_exists('timeout', $this->config)) { + if ($this->config['timeout'] === null) { $this->config['timeout'] = $this->option->getConnectTimeout(); } diff --git a/src/redis/src/RedisConfig.php b/src/redis/src/RedisConfig.php index fdea9e0cf..8680f8dd6 100644 --- a/src/redis/src/RedisConfig.php +++ b/src/redis/src/RedisConfig.php @@ -23,7 +23,7 @@ public function __construct(private Repository $config) } /** - * Get a single Redis connection config with merged options. + * Get a normalized Redis connection configuration. * * @return array */ @@ -37,25 +37,60 @@ public function connectionConfig(string $name): array } $connectionConfig = $this->parseConnectionConfiguration($connectionConfig); + $connectionConfig += [ + 'scheme' => null, + 'username' => null, + 'password' => null, + 'timeout' => null, + 'read_timeout' => 0.0, + 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, + 'max_retries' => 3, + 'backoff_algorithm' => 'decorrelated_jitter', + 'backoff_base' => 100, + 'backoff_cap' => 1000, + 'pool' => [], + ]; + + $sentinelConfig = $connectionConfig['sentinel'] ?? null; + + if (is_array($sentinelConfig) && ($sentinelConfig['enabled'] ?? false)) { + $sentinelConfig += [ + 'username' => null, + 'password' => null, + 'timeout' => 0.0, + 'read_timeout' => 0.0, + 'context' => [], + ]; + $connectionConfig['sentinel'] = $sentinelConfig; + } + $this->validateConnectionConfig($name, $connectionConfig); if ((bool) ($connectionConfig['cluster']['enabled'] ?? false)) { $connectionConfig = $this->normalizeClusterConfiguration($name, $connectionConfig); + } else { + $connectionConfig += [ + 'database' => 0, + 'name' => null, + ]; } - $sharedOptions = $redisConfig['options'] ?? []; + $sharedOptions = $redisConfig['options']; if (! is_array($sharedOptions)) { throw new InvalidArgumentException('The redis options config must be an array.'); } - $connectionOptions = $connectionConfig['options'] ?? []; + $connectionOptions = $connectionConfig['options']; if (! is_array($connectionOptions)) { throw new InvalidArgumentException(sprintf('The redis connection [%s] options must be an array.', $name)); } $connectionConfig['options'] = array_replace($sharedOptions, $connectionOptions); - if (array_key_exists('prefix', $connectionConfig)) { + if (isset($connectionConfig['prefix'])) { $connectionConfig['options']['prefix'] = $connectionConfig['prefix']; } @@ -140,7 +175,7 @@ private function validateConnectionConfig(string $name, mixed $connectionConfig) throw new InvalidArgumentException(sprintf('The redis connection [%s] must be an array.', $name)); } - $scheme = $connectionConfig['scheme'] ?? null; + $scheme = $connectionConfig['scheme']; if ($scheme !== null && (! is_string($scheme) || ! in_array($scheme, ['tcp', 'tls'], true))) { throw new InvalidArgumentException(sprintf( @@ -217,9 +252,9 @@ private function validateConnectionConfig(string $name, mixed $connectionConfig) private function normalizeClusterConfiguration(string $name, array $connectionConfig): array { /** @var null|'tcp'|'tls' $scheme */ - $scheme = $connectionConfig['scheme'] ?? null; + $scheme = $connectionConfig['scheme']; /** @var array $context */ - $context = $connectionConfig['context'] ?? []; + $context = $connectionConfig['context']; /** @var array $seeds */ $seeds = $connectionConfig['cluster']['seeds']; diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index 18084323f..d1a4b437b 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -352,28 +352,7 @@ abstract class RedisConnection extends BaseConnection protected ?Dispatcher $eventDispatcher = null; - protected array $config = [ - 'timeout' => 0.0, - 'retry_interval' => 0, - 'read_timeout' => 0.0, - 'cluster' => [ - 'enabled' => false, - 'seeds' => [], - ], - 'sentinel' => [ - 'enabled' => false, - 'master_name' => '', - 'nodes' => [], - 'username' => null, - 'password' => null, - 'timeout' => 0.0, - 'read_timeout' => 0.0, - 'context' => [], - ], - 'options' => [], - 'context' => [], - 'events' => false, - ]; + protected array $config; /** * Current redis database. @@ -398,7 +377,7 @@ abstract class RedisConnection extends BaseConnection public function __construct(Container $container, PoolInterface $pool, array $config) { parent::__construct($container, $pool); - $this->config = array_replace_recursive($this->config, $config); + $this->config = $config; } /** @@ -615,16 +594,16 @@ protected function configuredPhpRedisOptions(): array $connectionOptions = []; foreach (self::CONNECTION_LEVEL_PHPREDIS_OPTIONS as $key) { - if ($key === 'read_timeout' && empty($this->config[$key])) { + $value = $this->config[$key]; + + if ($key === 'read_timeout' && empty($value)) { continue; } - if (array_key_exists($key, $this->config)) { - $connectionOptions[$key] = $this->config[$key]; - } + $connectionOptions[$key] = $value; } - return array_replace($connectionOptions, $this->config['options'] ?? []); + return array_replace($connectionOptions, $this->config['options']); } /** @@ -777,10 +756,13 @@ public function release(): void } try { - $defaultDatabase = (int) ($this->config['database'] ?? 0); + // Cluster connections never select logical databases and omit this config member. + if ($this->database !== null) { + $defaultDatabase = $this->config['database']; - if ($this->database !== null && $this->database !== $defaultDatabase) { - $this->select($defaultDatabase); + if ($this->database !== $defaultDatabase) { + $this->select($defaultDatabase); + } } } catch (Throwable $exception) { $this->markInvalid(); diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index 101fb2fec..fd37ed365 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -521,8 +521,8 @@ public function subscriber(): Subscriber $config, $host, $port, - $config['scheme'] ?? null, - $config['context'] ?? [], + $config['scheme'], + $config['context'], ); } @@ -530,9 +530,9 @@ public function subscriber(): Subscriber return $this->createSubscriber( $config, $config['host'], - (int) $config['port'], - $config['scheme'] ?? null, - $config['context'] ?? [], + $config['port'], + $config['scheme'], + $config['context'], ); } @@ -582,8 +582,8 @@ public function subscriber(): Subscriber $config, $master[0], (int) $master[1], - $config['scheme'] ?? null, - $config['context'] ?? [], + $config['scheme'], + $config['context'], ); } catch (Throwable $exception) { $failures[] = sprintf( @@ -615,17 +615,17 @@ private function createSubscriber( array $context, ): Subscriber { /** @var null|array|string $password */ - $password = $config['password'] ?? null; + $password = $config['password']; /** @var null|string $username */ - $username = $config['username'] ?? null; + $username = $config['username']; return new Subscriber( host: $host, port: $port, password: $password, - timeout: (float) ($config['timeout'] ?? 5.0), - prefix: (string) (($config['options'] ?? [])['prefix'] ?? ''), + timeout: $config['timeout'], + prefix: (string) ($config['options']['prefix'] ?? ''), username: $username, scheme: $scheme, context: $context, diff --git a/src/redis/src/RedisSentinelFactory.php b/src/redis/src/RedisSentinelFactory.php index 8bb8f64da..76fd3d24b 100644 --- a/src/redis/src/RedisSentinelFactory.php +++ b/src/redis/src/RedisSentinelFactory.php @@ -29,8 +29,8 @@ public function create(array $options = []): RedisSentinel */ public function resolveMaster(array $config): array { - $sentinel = $config['sentinel'] ?? []; - $nodes = $sentinel['nodes'] ?? []; + $sentinel = $config['sentinel']; + $nodes = $sentinel['nodes']; $failures = []; shuffle($nodes); @@ -62,25 +62,25 @@ public function resolveMaster(array $config): array ? "{$resolved['scheme']}://{$resolved['host']}" : $resolved['host'], 'port' => (int) $resolved['port'], - 'connectTimeout' => (float) ($sentinel['timeout'] ?? 0), - 'readTimeout' => (float) ($sentinel['read_timeout'] ?? 0), + 'connectTimeout' => $sentinel['timeout'], + 'readTimeout' => $sentinel['read_timeout'], ]; - $context = $sentinel['context'] ?? []; - $password = $sentinel['password'] ?? null; + $context = $sentinel['context']; + $password = $sentinel['password']; if ($context !== []) { $options['ssl'] = $this->normalizeContext($context); } if ($password !== null && $password !== '') { - $username = $sentinel['username'] ?? null; + $username = $sentinel['username']; $options['auth'] = $username !== null && $username !== '' && is_string($password) ? [$username, $password] : $password; } $master = $this->create($options)->getMasterAddrByName( - (string) ($sentinel['master_name'] ?? '') + $sentinel['master_name'] ); if (is_array($master) @@ -99,7 +99,7 @@ public function resolveMaster(array $config): array throw new InvalidRedisConnectionException(sprintf( 'Unable to resolve Redis master [%s] from Sentinel nodes: %s.', - $sentinel['master_name'] ?? '', + $sentinel['master_name'], implode('; ', $failures), )); } diff --git a/src/reverb/config/reverb.php b/src/reverb/config/reverb.php index d990d52b3..a9d178a50 100644 --- a/src/reverb/config/reverb.php +++ b/src/reverb/config/reverb.php @@ -2,6 +2,8 @@ declare(strict_types=1); +$maxConnections = env('REVERB_APP_MAX_CONNECTIONS'); + return [ /* |-------------------------------------------------------------------------- @@ -15,7 +17,7 @@ | */ - 'enabled' => env('REVERB_ENABLED', true), + 'enabled' => (bool) env('REVERB_ENABLED', true), /* |-------------------------------------------------------------------------- @@ -44,12 +46,12 @@ 'servers' => [ 'reverb' => [ 'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), - 'port' => env('REVERB_SERVER_PORT', 8080), + 'port' => (int) env('REVERB_SERVER_PORT', 8080), 'path' => env('REVERB_SERVER_PATH', ''), 'options' => [ 'tls' => [], ], - 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'max_request_size' => (int) env('REVERB_MAX_REQUEST_SIZE', 10_000), /* |-------------------------------------------------------------- | Multi-Instance Scaling via Redis @@ -74,7 +76,7 @@ */ 'scaling' => [ - 'enabled' => env('REVERB_SCALING_ENABLED', false), + 'enabled' => (bool) env('REVERB_SCALING_ENABLED', false), 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), 'connection' => env('REVERB_SCALING_CONNECTION', 'reverb'), ], @@ -101,14 +103,14 @@ */ 'swoole_shared_state' => [ - 'rows' => env('REVERB_SWOOLE_SHARED_STATE_ROWS', 65536), + 'rows' => (int) env('REVERB_SWOOLE_SHARED_STATE_ROWS', 65536), // Rows for the webhook throttle/dedupe lock table. Only // used for subscription_count throttling, cache_miss // deduplication, and disconnect smoothing markers. A small // fraction of channels need lock rows, so this can be much // smaller than the main table. - 'lock_rows' => env('REVERB_SWOOLE_SHARED_STATE_LOCK_ROWS', 8192), + 'lock_rows' => (int) env('REVERB_SWOOLE_SHARED_STATE_LOCK_ROWS', 8192), ], ], ], @@ -121,6 +123,7 @@ | Here you may define how Reverb applications are managed. If you choose | to use the "config" provider, you may define an array of apps which | your server will support, including their connection credentials. + | A null max_connections value allows unlimited connections for an app. | */ @@ -134,21 +137,21 @@ 'app_id' => env('REVERB_APP_ID'), 'options' => [ 'host' => env('REVERB_HOST'), - 'port' => env('REVERB_PORT', 443), + 'port' => (int) env('REVERB_PORT', 443), 'scheme' => env('REVERB_SCHEME', 'https'), 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', ], 'allowed_origins' => ['*'], - 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), - 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), - 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), - 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + 'ping_interval' => (int) env('REVERB_APP_PING_INTERVAL', 60), + 'activity_timeout' => (int) env('REVERB_APP_ACTIVITY_TIMEOUT', 30), + 'max_connections' => $maxConnections === null ? null : (int) $maxConnections, + 'max_message_size' => (int) env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), 'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'), 'rate_limiting' => [ - 'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false), - 'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60), - 'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60), - 'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false), + 'enabled' => (bool) env('REVERB_APP_RATE_LIMIT_ENABLED', false), + 'max_attempts' => (int) env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60), + 'decay_seconds' => (int) env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60), + 'terminate_on_limit' => (bool) env('REVERB_APP_RATE_LIMIT_TERMINATE_ON_LIMIT', false), ], /* |-------------------------------------------------------------- @@ -163,6 +166,8 @@ | on the "reverb-webhooks" Redis queue. Payloads are signed | with HMAC-SHA256 using the app secret and include an | X-Pusher-Key header for app identification. + | A null or empty URL disables webhooks. A null prefix filter + | matches any prefix, while a null suffix matches any suffix. | | Enable batching for production workloads — it consolidates | many events into fewer HTTP requests, significantly reducing @@ -196,24 +201,24 @@ // subscribe/unsubscribe for non-presence channels. Throttled // to once per 5 seconds for channels with over 100 subscribers. // Controlled separately from the events list above. - 'subscription_count' => env('REVERB_WEBHOOK_SUBSCRIPTION_COUNT', false), + 'subscription_count' => (bool) env('REVERB_WEBHOOK_SUBSCRIPTION_COUNT', false), // Delay in milliseconds before firing channel_vacated and // member_removed webhooks after a client disconnects. If the // client reconnects within this window, both the removal and // the subsequent re-addition webhooks are suppressed. Set to // 0 to disable and fire immediately on disconnect. - 'disconnect_smoothing_ms' => env('REVERB_WEBHOOK_DISCONNECT_SMOOTHING_MS', 3000), + 'disconnect_smoothing_ms' => (int) env('REVERB_WEBHOOK_DISCONNECT_SMOOTHING_MS', 3000), - 'timeout' => env('REVERB_WEBHOOK_TIMEOUT', 5), - 'retries' => env('REVERB_WEBHOOK_RETRIES', 3), - 'retry_delay' => env('REVERB_WEBHOOK_RETRY_DELAY', 1), + 'timeout' => (int) env('REVERB_WEBHOOK_TIMEOUT', 5), + 'retries' => (int) env('REVERB_WEBHOOK_RETRIES', 3), + 'retry_delay' => (int) env('REVERB_WEBHOOK_RETRY_DELAY', 1), 'batching' => [ - 'enabled' => env('REVERB_WEBHOOK_BATCHING_ENABLED', false), - 'max_events' => env('REVERB_WEBHOOK_BATCHING_MAX_EVENTS', 50), - 'max_delay_ms' => env('REVERB_WEBHOOK_BATCHING_MAX_DELAY_MS', 250), - 'max_payload_bytes' => env('REVERB_WEBHOOK_BATCHING_MAX_PAYLOAD_BYTES', 262144), + 'enabled' => (bool) env('REVERB_WEBHOOK_BATCHING_ENABLED', false), + 'max_events' => (int) env('REVERB_WEBHOOK_BATCHING_MAX_EVENTS', 50), + 'max_delay_ms' => (int) env('REVERB_WEBHOOK_BATCHING_MAX_DELAY_MS', 250), + 'max_payload_bytes' => (int) env('REVERB_WEBHOOK_BATCHING_MAX_PAYLOAD_BYTES', 262144), ], ], ], diff --git a/src/reverb/src/Application.php b/src/reverb/src/Application.php index de39b3fe2..1970af9ee 100644 --- a/src/reverb/src/Application.php +++ b/src/reverb/src/Application.php @@ -6,6 +6,16 @@ class Application { + /** + * The default application activity timeout in seconds. + */ + public const int DEFAULT_ACTIVITY_TIMEOUT = 30; + + /** + * The default client-event sender policy. + */ + public const string DEFAULT_ACCEPT_CLIENT_EVENTS_FROM = 'members'; + /** * Create a new application instance. */ @@ -18,11 +28,59 @@ public function __construct( protected array $allowedOrigins, protected int $maxMessageSize, protected ?int $maxConnections = null, - protected string $acceptClientEventsFrom = 'members', + protected string $acceptClientEventsFrom = self::DEFAULT_ACCEPT_CLIENT_EVENTS_FROM, protected ?array $rateLimiting = null, protected array $options = [], protected array $webhooks = [], ) { + if ($this->rateLimiting !== null) { + $this->rateLimiting += [ + 'enabled' => false, + 'max_attempts' => 60, + 'decay_seconds' => 60, + 'terminate_on_limit' => false, + ]; + + $this->rateLimiting['enabled'] = (bool) $this->rateLimiting['enabled']; + $this->rateLimiting['max_attempts'] = (int) $this->rateLimiting['max_attempts']; + $this->rateLimiting['decay_seconds'] = (int) $this->rateLimiting['decay_seconds']; + $this->rateLimiting['terminate_on_limit'] = (bool) $this->rateLimiting['terminate_on_limit']; + } + + if ($this->webhooks !== []) { + $this->webhooks += [ + 'url' => null, + 'events' => [], + 'headers' => [], + 'filter' => [], + 'subscription_count' => false, + 'disconnect_smoothing_ms' => 3000, + 'timeout' => 5, + 'retries' => 3, + 'retry_delay' => 1, + 'batching' => [], + ]; + $this->webhooks['filter'] += [ + 'channel_name_starts_with' => null, + 'channel_name_ends_with' => null, + ]; + $this->webhooks['batching'] += [ + 'enabled' => false, + 'max_events' => 50, + 'max_delay_ms' => 250, + 'max_payload_bytes' => 262_144, + ]; + + $this->webhooks['subscription_count'] = (bool) $this->webhooks['subscription_count']; + $this->webhooks['disconnect_smoothing_ms'] = (int) $this->webhooks['disconnect_smoothing_ms']; + $this->webhooks['timeout'] = (int) $this->webhooks['timeout']; + $this->webhooks['retries'] = (int) $this->webhooks['retries']; + $this->webhooks['retry_delay'] = (int) $this->webhooks['retry_delay']; + $this->webhooks['batching']['enabled'] = (bool) $this->webhooks['batching']['enabled']; + $this->webhooks['batching']['max_events'] = (int) $this->webhooks['batching']['max_events']; + $this->webhooks['batching']['max_delay_ms'] = (int) $this->webhooks['batching']['max_delay_ms']; + $this->webhooks['batching']['max_payload_bytes'] = (int) $this->webhooks['batching']['max_payload_bytes']; + } } /** @@ -120,7 +178,7 @@ public function rateLimiting(): ?array */ public function usesRateLimiting(): bool { - return ($this->rateLimiting['enabled'] ?? false) === true; + return $this->rateLimiting !== null && $this->rateLimiting['enabled']; } /** @@ -144,12 +202,21 @@ public function webhooks(): array */ public function hasWebhooks(): bool { - return ! empty($this->webhooks['url']); + if ($this->webhooks === []) { + return false; + } + + $url = $this->webhooks['url']; + + return $url !== null && $url !== ''; } /** * Convert the application to an array. * + * This is the Pusher client configuration shape, not the complete + * application constructor or configuration record. + * * @return array */ public function toArray(): array diff --git a/src/reverb/src/ConfigApplicationProvider.php b/src/reverb/src/ConfigApplicationProvider.php index 41e9c421b..31231d3c0 100644 --- a/src/reverb/src/ConfigApplicationProvider.php +++ b/src/reverb/src/ConfigApplicationProvider.php @@ -68,16 +68,18 @@ public function find(string $key, mixed $value): Application */ protected function buildApplication(array $app): Application { + $maxConnections = $app['max_connections'] ?? null; + return new Application( $app['app_id'], $app['key'], $app['secret'], (int) $app['ping_interval'], - (int) ($app['activity_timeout'] ?? 30), + (int) ($app['activity_timeout'] ?? Application::DEFAULT_ACTIVITY_TIMEOUT), $app['allowed_origins'], (int) $app['max_message_size'], - isset($app['max_connections']) ? (int) $app['max_connections'] : null, - $app['accept_client_events_from'] ?? 'members', + $maxConnections === null ? null : (int) $maxConnections, + $app['accept_client_events_from'] ?? Application::DEFAULT_ACCEPT_CLIENT_EVENTS_FROM, $app['rate_limiting'] ?? null, $app['options'] ?? [], $app['webhooks'] ?? [], diff --git a/src/reverb/src/Protocols/Pusher/Channels/Channel.php b/src/reverb/src/Protocols/Pusher/Channels/Channel.php index ed723b8e1..773cfdf59 100644 --- a/src/reverb/src/Protocols/Pusher/Channels/Channel.php +++ b/src/reverb/src/Protocols/Pusher/Channels/Channel.php @@ -161,7 +161,7 @@ protected function handleChannelOccupied(Connection $connection, SharedState $sh // the smoothing window), suppress the channel_occupied webhook — the // channel was never truly vacated from the consumer's perspective. if ($app->hasWebhooks()) { - $smoothingMs = (int) ($app->webhooks()['disconnect_smoothing_ms'] ?? 3000); + $smoothingMs = $app->webhooks()['disconnect_smoothing_ms']; $cancelledLocally = app(DeferredWebhookManager::class)->cancelChannelVacated( $app->id(), @@ -269,7 +269,7 @@ protected function handleChannelVacated(Connection $connection): void return; } - $delayMs = (int) ($app->webhooks()['disconnect_smoothing_ms'] ?? 3000); + $delayMs = $app->webhooks()['disconnect_smoothing_ms']; $manager = app(DeferredWebhookManager::class); if ($delayMs > 0 && $connection->isDisconnecting() && ! $manager->isDraining()) { @@ -297,9 +297,14 @@ protected function dispatchSubscriptionCountWebhook( } $app = $connection->app(); + + if (! $app->hasWebhooks()) { + return; + } + $webhooks = $app->webhooks(); - if (! $app->hasWebhooks() || ! ($webhooks['subscription_count'] ?? false)) { + if (! $webhooks['subscription_count']) { return; } diff --git a/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php b/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php index 8a8177564..4114aec94 100644 --- a/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php +++ b/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php @@ -59,7 +59,7 @@ public function subscribe(Connection $connection, ?string $auth = null, ?string $app = $connection->app(); if ($presenceUserId !== '' && $app->hasWebhooks()) { - $smoothingMs = (int) ($app->webhooks()['disconnect_smoothing_ms'] ?? 3000); + $smoothingMs = $app->webhooks()['disconnect_smoothing_ms']; $cancelledLocally = app(DeferredWebhookManager::class)->cancelMemberRemoved( $app->id(), @@ -120,7 +120,7 @@ public function unsubscribe(Connection $connection, ?string $userId = null): voi $app = $connection->app(); if ($app->hasWebhooks()) { - $delayMs = (int) ($app->webhooks()['disconnect_smoothing_ms'] ?? 3000); + $delayMs = $app->webhooks()['disconnect_smoothing_ms']; $manager = app(DeferredWebhookManager::class); if ($delayMs > 0 && $connection->isDisconnecting() && ! $manager->isDraining()) { diff --git a/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php b/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php index 6f7b0a634..8d5510d7c 100644 --- a/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php +++ b/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php @@ -72,7 +72,7 @@ protected function verifySignature(Request $request, Application $application, s $path = $request->getPathInfo(); - if ($prefix = config('reverb.servers.reverb.path')) { + if ($prefix = config()->string('reverb.servers.reverb.path')) { $path = '/' . ltrim(Str::after($path, rtrim($prefix, '/')), '/'); } diff --git a/src/reverb/src/Protocols/Pusher/Server.php b/src/reverb/src/Protocols/Pusher/Server.php index 4d3e214dc..9c46f52e6 100644 --- a/src/reverb/src/Protocols/Pusher/Server.php +++ b/src/reverb/src/Protocols/Pusher/Server.php @@ -138,7 +138,7 @@ public function message(Connection $from, string $message): void } } catch (Throwable $e) { $terminateOnLimit = $e instanceof RateLimitExceeded - && ($from->app()->rateLimiting()['terminate_on_limit'] ?? false); + && $from->app()->rateLimiting()['terminate_on_limit']; try { $this->error($from, $e); @@ -292,7 +292,7 @@ protected function messageLimit(Connection $connection): Limit { $config = $connection->app()->rateLimiting(); - return Limit::perSecond((int) $config['max_attempts'], (int) $config['decay_seconds']) + return Limit::perSecond($config['max_attempts'], $config['decay_seconds']) ->by('reverb:message:' . $connection->id()); } diff --git a/src/reverb/src/ReverbServiceProvider.php b/src/reverb/src/ReverbServiceProvider.php index 8db946171..6de072a34 100644 --- a/src/reverb/src/ReverbServiceProvider.php +++ b/src/reverb/src/ReverbServiceProvider.php @@ -147,7 +147,7 @@ protected function registerWebSocketServer(): void $config = $this->app->make('config'); $reverbServer = $config->array('reverb.servers.reverb'); - $servers = $config->array('server.servers', []); + $servers = $config->array('server.servers'); /** @var array $tlsConfiguration */ $tlsConfiguration = $reverbServer['options']['tls']; $tls = TlsOptions::fromArray($tlsConfiguration); @@ -156,7 +156,7 @@ protected function registerWebSocketServer(): void 'name' => 'reverb', 'type' => ServerInterface::SERVER_WEBSOCKET, 'host' => $reverbServer['host'], - 'port' => (int) $reverbServer['port'], + 'port' => $reverbServer['port'], 'sock_type' => $tls->socketType(), 'callbacks' => [ Event::ON_REQUEST => [HttpServer::class, 'onRequest'], @@ -488,7 +488,7 @@ protected function flushWebhookBuffers(): void $webhooks = $app->webhooks(); - if (! ($webhooks['batching']['enabled'] ?? false)) { + if (! $webhooks['batching']['enabled']) { continue; } @@ -566,7 +566,7 @@ protected function recoverStaleWebhookBatches(): void $webhooks = $app->webhooks(); - if (! ($webhooks['batching']['enabled'] ?? false)) { + if (! $webhooks['batching']['enabled']) { continue; } diff --git a/src/reverb/src/Servers/Hypervel/HypervelServerProvider.php b/src/reverb/src/Servers/Hypervel/HypervelServerProvider.php index d44614c3b..92b42422e 100644 --- a/src/reverb/src/Servers/Hypervel/HypervelServerProvider.php +++ b/src/reverb/src/Servers/Hypervel/HypervelServerProvider.php @@ -35,7 +35,7 @@ public function __construct( protected Container $app, protected array $config, ) { - $this->publishesEvents = (bool) $this->config['scaling']['enabled']; + $this->publishesEvents = $this->config['scaling']['enabled']; } /** @@ -55,12 +55,12 @@ public function register(): void // in the main process so they're shared across all workers via // copy-on-write. Using instance() instead of singleton() ensures // the object is created now, not lazily in a worker. - $rows = (int) $this->config['swoole_shared_state']['rows']; + $rows = $this->config['swoole_shared_state']['rows']; $table = new Table($rows); $table->column('count', Table::TYPE_INT); $table->create(); - $lockRows = (int) $this->config['swoole_shared_state']['lock_rows']; + $lockRows = $this->config['swoole_shared_state']['lock_rows']; $lockTable = new Table($lockRows); $lockTable->column('locked_at', Table::TYPE_FLOAT); $lockTable->create(); diff --git a/src/reverb/src/Webhooks/HttpWebhookDispatcher.php b/src/reverb/src/Webhooks/HttpWebhookDispatcher.php index c68c3ac92..1d8283599 100644 --- a/src/reverb/src/Webhooks/HttpWebhookDispatcher.php +++ b/src/reverb/src/Webhooks/HttpWebhookDispatcher.php @@ -25,7 +25,7 @@ public function dispatch(Application $application, string $event, array $data = } $webhooks = $application->webhooks(); - $allowedEvents = $webhooks['events'] ?? []; + $allowedEvents = $webhooks['events']; // subscription_count has its own opt-in (webhooks.subscription_count boolean) // and bypasses the events allowlist — it's already gated by the caller. @@ -33,14 +33,14 @@ public function dispatch(Application $application, string $event, array $data = return; } - $channelPrefix = $webhooks['filter']['channel_name_starts_with'] ?? null; + $channelPrefix = $webhooks['filter']['channel_name_starts_with']; if ($channelPrefix !== null && isset($data['channel'])) { if (! str_starts_with($data['channel'], $channelPrefix)) { return; } } - $channelSuffix = $webhooks['filter']['channel_name_ends_with'] ?? null; + $channelSuffix = $webhooks['filter']['channel_name_ends_with']; if ($channelSuffix !== null && isset($data['channel'])) { if (! str_ends_with($data['channel'], $channelSuffix)) { return; @@ -49,9 +49,7 @@ public function dispatch(Application $application, string $event, array $data = $eventData = $this->buildEventData($application, $event, $data, $connection); - $batchingEnabled = (bool) ($webhooks['batching']['enabled'] ?? false); - - if ($batchingEnabled) { + if ($webhooks['batching']['enabled']) { $buffer = app(WebhookBatchBuffer::class); $shouldSchedule = $buffer->appendAndCheckSchedule($application->id(), $eventData); @@ -60,7 +58,7 @@ public function dispatch(Application $application, string $event, array $data = FlushWebhookBatchJob::dispatch($application->id(), $webhooks) ->onQueue('reverb-webhook-flush') ->delay(now()->addMilliseconds( - (int) ($webhooks['batching']['max_delay_ms'] ?? 250) + $webhooks['batching']['max_delay_ms'] )); } catch (Throwable $exception) { try { @@ -85,10 +83,10 @@ public function dispatch(Application $application, string $event, array $data = $webhooks['url'], $application->key(), $application->secret(), - (int) ($webhooks['retries'] ?? 3), - (int) ($webhooks['retry_delay'] ?? 1), - (int) ($webhooks['timeout'] ?? 5), - $webhooks['headers'] ?? [], + $webhooks['retries'], + $webhooks['retry_delay'], + $webhooks['timeout'], + $webhooks['headers'], ); } } diff --git a/src/reverb/src/Webhooks/Jobs/FlushWebhookBatchJob.php b/src/reverb/src/Webhooks/Jobs/FlushWebhookBatchJob.php index 781511a5b..d931bd0e2 100644 --- a/src/reverb/src/Webhooks/Jobs/FlushWebhookBatchJob.php +++ b/src/reverb/src/Webhooks/Jobs/FlushWebhookBatchJob.php @@ -41,8 +41,8 @@ public function handle(WebhookBatchBuffer $buffer): void $buffer->clearFlushLock($this->appId); $config = $this->webhookConfig; - $maxEvents = (int) ($config['batching']['max_events'] ?? 50); - $maxBytes = (int) ($config['batching']['max_payload_bytes'] ?? 262144); + $maxEvents = $config['batching']['max_events']; + $maxBytes = $config['batching']['max_payload_bytes']; // Claim events atomically — moves them from buffer to processing hash. // If claim returns empty, either the buffer is empty or another flush @@ -68,10 +68,10 @@ public function handle(WebhookBatchBuffer $buffer): void $config['url'], $application->key(), $application->secret(), - (int) ($config['retries'] ?? 3), - (int) ($config['retry_delay'] ?? 1), - (int) ($config['timeout'] ?? 5), - $config['headers'] ?? [], + $config['retries'], + $config['retry_delay'], + $config['timeout'], + $config['headers'], ); // Acknowledge — delete the processing key now that delivery is queued diff --git a/src/routing/src/RoutingServiceProvider.php b/src/routing/src/RoutingServiceProvider.php index bdc7f9684..3d4197b76 100644 --- a/src/routing/src/RoutingServiceProvider.php +++ b/src/routing/src/RoutingServiceProvider.php @@ -57,7 +57,7 @@ public function reloadConfiguration(): void $config = $this->app->make('config'); $url = $this->app->make('url'); - $url->setRequest(Request::create($config->string('app.url'))); + $url->setRequest(Request::create($config->get('app.url') ?? 'http://localhost')); $url->setAssetRoot($config->get('app.asset_url')); $url->forceHttps($config->boolean('app.force_https')); } diff --git a/src/saloon/config/saloon.php b/src/saloon/config/saloon.php index 802f8e1d6..f7bf32bac 100644 --- a/src/saloon/config/saloon.php +++ b/src/saloon/config/saloon.php @@ -5,6 +5,17 @@ use GuzzleHttp\TransportSharing; return [ + /* + |-------------------------------------------------------------------------- + | HTTP Connection + |-------------------------------------------------------------------------- + | + | Saloon sends requests through this named, worker-lifetime HTTP + | connection. Its options are an open transport preset and may be + | adjusted or removed independently. + | + */ + 'connection' => [ 'name' => 'saloon', 'options' => [ @@ -14,6 +25,16 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Default Stores + |-------------------------------------------------------------------------- + | + | Set either store to null to use the corresponding framework default. + | Individual connectors and requests may select another configured store. + | + */ + 'cache' => [ 'store' => null, ], @@ -22,11 +43,32 @@ 'store' => null, ], + /* + |-------------------------------------------------------------------------- + | Fixtures + |-------------------------------------------------------------------------- + | + | Missing fixture settings use the values shown below. When + | "throw_on_missing" is false, Saloon records a real response for a + | missing fixture. Enable it for replay-only test runs such as CI. + | + */ + 'fixtures' => [ 'path' => base_path('tests/Fixtures/Saloon'), 'throw_on_missing' => false, ], + /* + |-------------------------------------------------------------------------- + | Generated Integrations + |-------------------------------------------------------------------------- + | + | The path and namespace are independent. A null namespace derives + | "Http\\Integrations" beneath the application's root namespace. + | + */ + 'integrations_path' => app_path('Http/Integrations'), 'integrations_namespace' => null, ]; diff --git a/src/saloon/src/SaloonManager.php b/src/saloon/src/SaloonManager.php index 9552cb99c..6d08440c5 100644 --- a/src/saloon/src/SaloonManager.php +++ b/src/saloon/src/SaloonManager.php @@ -409,7 +409,11 @@ public function fixturePath(string $path): static */ public function getFixturePath(): string { - return $this->fixturePath ?? $this->config->string('saloon.fixtures.path'); + return $this->fixturePath + ?? $this->config->string( + 'saloon.fixtures.path', + static fn (): string => base_path('tests/Fixtures/Saloon'), + ); } /** @@ -431,7 +435,7 @@ public function throwOnMissingFixtures(bool $throw = true): static public function throwsOnMissingFixtures(): bool { return $this->throwOnMissingFixtures - ?? $this->config->boolean('saloon.fixtures.throw_on_missing'); + ?? $this->config->boolean('saloon.fixtures.throw_on_missing', false); } /** diff --git a/src/sanctum/config/sanctum.php b/src/sanctum/config/sanctum.php index c3b3dddd1..fd5625ad3 100644 --- a/src/sanctum/config/sanctum.php +++ b/src/sanctum/config/sanctum.php @@ -28,6 +28,7 @@ | This value controls the number of minutes until an issued token will be | considered expired. This will override any values set in the token's | "expires_at" attribute, but first-party sessions are not affected. + | Set to null to rely only on each token's expires_at value. | */ @@ -43,7 +44,7 @@ | */ - 'last_used_at' => env('SANCTUM_LAST_USED_AT', true), + 'last_used_at' => (bool) env('SANCTUM_LAST_USED_AT', true), /* |-------------------------------------------------------------------------- @@ -67,7 +68,9 @@ | | When authenticating your first-party SPA with Sanctum you may need to | customize some of the middleware Sanctum uses while processing the - | request. You may change the middleware below as required. + | request. Omitted cookie-encryption and CSRF entries use Sanctum's + | defaults, while session authentication is omitted by default. Set any + | entry to null to remove that middleware from the request pipeline. | */ @@ -85,20 +88,36 @@ | When enabled, Sanctum will cache token and tokenable lookups to improve | performance. The last_used_at timestamp will be updated at the specified | interval instead of on every request to reduce database writes. The TTL - | is the maximum time a cached tokenable identity may remain stale. + | is the maximum time a cached tokenable identity may remain stale. A + | null store uses the default cache store. The cache record may be omitted + | to disable caching. Its other members default to the values shown below. + | The update interval accepts zero to write after every authentication. | */ 'cache' => [ - 'enabled' => env('SANCTUM_CACHE_ENABLED', false), - 'store' => env('SANCTUM_CACHE_STORE'), // Uses default store if not set - 'ttl' => (int) env('SANCTUM_CACHE_TTL', 300), // 5 minutes + 'enabled' => (bool) env('SANCTUM_CACHE_ENABLED', false), + 'store' => env('SANCTUM_CACHE_STORE'), + 'ttl' => (int) env('SANCTUM_CACHE_TTL', 300), 'prefix' => env('SANCTUM_CACHE_PREFIX', 'sanctum'), - // Zero is valid, so preserve malformed values for startup validation. 'last_used_at_update_interval' => filter_var( - env('SANCTUM_LAST_USED_UPDATE_INTERVAL', 300), + env('SANCTUM_LAST_USED_AT_UPDATE_INTERVAL', 300), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE, ), ], + + /* + |-------------------------------------------------------------------------- + | Routes + |-------------------------------------------------------------------------- + | + | Disable route registration when the application provides its own CSRF + | cookie endpoint. The prefix applies to Sanctum's built-in route. + | + */ + + 'routes' => true, + + 'prefix' => 'sanctum', ]; diff --git a/src/sanctum/src/Console/Commands/PruneExpired.php b/src/sanctum/src/Console/Commands/PruneExpired.php index e68dfc446..147d6190b 100644 --- a/src/sanctum/src/Console/Commands/PruneExpired.php +++ b/src/sanctum/src/Console/Commands/PruneExpired.php @@ -42,12 +42,18 @@ public function handle(): int return Command::FAILURE; } + $expiration = config()->get('sanctum.expiration'); + + if ($expiration !== null) { + $expiration = config()->integer('sanctum.expiration'); + } + $this->info('Pruning tokens with expired expires_at timestamps...'); $expiredCount = $model::where('expires_at', '<', now()->subHours($hours))->delete(); $this->info("Pruned {$expiredCount} expired tokens."); - if ($expiration = config('sanctum.expiration')) { + if ($expiration) { $this->info('Pruning tokens with expired expiration value based on configuration file...'); $configExpiredCount = $model::where('created_at', '<', now()->subMinutes($expiration + ($hours * 60)))->delete(); diff --git a/src/sanctum/src/HasApiTokens.php b/src/sanctum/src/HasApiTokens.php index 5cadd174e..a538ef430 100644 --- a/src/sanctum/src/HasApiTokens.php +++ b/src/sanctum/src/HasApiTokens.php @@ -97,7 +97,7 @@ public function generateTokenString(): string { return sprintf( '%s%s%s', - config('sanctum.token_prefix', ''), + config()->string('sanctum.token_prefix'), $tokenEntropy = Str::random(40), hash('crc32b', $tokenEntropy) ); diff --git a/src/sanctum/src/Http/Middleware/AuthenticateSession.php b/src/sanctum/src/Http/Middleware/AuthenticateSession.php index f5e229a8c..dd89ff851 100644 --- a/src/sanctum/src/Http/Middleware/AuthenticateSession.php +++ b/src/sanctum/src/Http/Middleware/AuthenticateSession.php @@ -100,7 +100,7 @@ protected function sanctumSessionGuards(): array { $sessionGuards = []; - foreach ($this->config->array('auth.guards', []) as $guard) { + foreach ($this->config->array('auth.guards') as $guard) { if (! is_array($guard) || ($guard['driver'] ?? null) !== 'sanctum') { continue; } diff --git a/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php b/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php index edb4563b7..1ac9f08ec 100644 --- a/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php +++ b/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php @@ -5,9 +5,13 @@ namespace Hypervel\Sanctum\Http\Middleware; use Closure; +use Hypervel\Cookie\Middleware\AddQueuedCookiesToResponse; +use Hypervel\Cookie\Middleware\EncryptCookies; +use Hypervel\Foundation\Http\Middleware\PreventRequestForgery; use Hypervel\Http\Request; use Hypervel\Routing\Pipeline; use Hypervel\Sanctum\Sanctum; +use Hypervel\Session\Middleware\StartSession; use Hypervel\Support\Collection; use Hypervel\Support\Str; use Symfony\Component\HttpFoundation\Response; @@ -50,12 +54,18 @@ public function handle(Request $request, Closure $next): Response */ protected function frontendMiddleware(): array { + $configuredMiddleware = array_replace([ + 'authenticate_session' => null, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => PreventRequestForgery::class, + ], config()->array('sanctum.middleware', [])); + $middleware = [ - config('sanctum.middleware.encrypt_cookies', \Hypervel\Cookie\Middleware\EncryptCookies::class), - \Hypervel\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Hypervel\Session\Middleware\StartSession::class, - config('sanctum.middleware.validate_csrf_token', \Hypervel\Foundation\Http\Middleware\PreventRequestForgery::class), - config('sanctum.middleware.authenticate_session'), + $configuredMiddleware['encrypt_cookies'], + AddQueuedCookiesToResponse::class, + StartSession::class, + $configuredMiddleware['validate_csrf_token'], + $configuredMiddleware['authenticate_session'], ]; $filtered = []; @@ -116,7 +126,7 @@ private static function resolveStatefulDomains(Request $request): array return self::filterDomainList((static::$statefulDomainsResolver)($request)); } - return self::filterDomainList(config('sanctum.stateful_domains', [])); + return self::filterDomainList(config()->array('sanctum.stateful_domains')); } /** diff --git a/src/sanctum/src/PersonalAccessToken.php b/src/sanctum/src/PersonalAccessToken.php index 8fb905c6a..7192fc9bb 100644 --- a/src/sanctum/src/PersonalAccessToken.php +++ b/src/sanctum/src/PersonalAccessToken.php @@ -69,7 +69,7 @@ protected static function boot(): void parent::boot(); static::created(function (self $token): void { - if (! config('sanctum.cache.enabled')) { + if (! config()->boolean('sanctum.cache.enabled', false)) { return; } @@ -82,7 +82,7 @@ protected static function boot(): void }); static::updated(function (self $token): void { - if (! config('sanctum.cache.enabled')) { + if (! config()->boolean('sanctum.cache.enabled', false)) { return; } @@ -102,7 +102,7 @@ protected static function boot(): void }); static::deleted(function (self $token): void { - if (! config('sanctum.cache.enabled')) { + if (! config()->boolean('sanctum.cache.enabled', false)) { return; } @@ -144,7 +144,7 @@ public static function findToken(string $token): ?static return null; } - $accessToken = config('sanctum.cache.enabled') + $accessToken = config()->boolean('sanctum.cache.enabled', false) ? static::findTokenUsingCache($id) : static::find($id); @@ -168,7 +168,7 @@ protected static function findTokenUsingCache(string $id): ?static return $cache->rememberNullable( static::getCacheKey($id), - config('sanctum.cache.ttl'), + config()->integer('sanctum.cache.ttl', Sanctum::DEFAULT_CACHE_TTL), fn () => static::find($id)?->unsetRelation('tokenable') ); } @@ -182,7 +182,7 @@ public static function findTokenable(PersonalAccessToken $accessToken): ?Authent return $accessToken->getRelation('tokenable'); } - if (! config('sanctum.cache.enabled')) { + if (! config()->boolean('sanctum.cache.enabled', false)) { return $accessToken->getAttribute('tokenable'); } @@ -198,7 +198,7 @@ public static function findTokenable(PersonalAccessToken $accessToken): ?Authent $tokenable = $accessToken->getAttribute('tokenable'); if ($tokenable instanceof Authenticatable) { - $cache->put($cacheKey, $tokenable, config('sanctum.cache.ttl')); + $cache->put($cacheKey, $tokenable, config()->integer('sanctum.cache.ttl', Sanctum::DEFAULT_CACHE_TTL)); } else { $tokenable = null; } @@ -252,13 +252,16 @@ protected static function forgetTokenEntry(CacheRepository $cache, int|string $t public function updateLastUsedAt(): void { $now = now(); - $cacheEnabled = (bool) config('sanctum.cache.enabled'); + $cacheEnabled = config()->boolean('sanctum.cache.enabled', false); if ( $cacheEnabled && $this->last_used_at !== null && $this->last_used_at->diffInSeconds($now) - < config('sanctum.cache.last_used_at_update_interval') + < config()->integer( + 'sanctum.cache.last_used_at_update_interval', + Sanctum::DEFAULT_LAST_USED_AT_UPDATE_INTERVAL, + ) ) { return; } @@ -282,7 +285,7 @@ public function updateLastUsedAt(): void /** @var int|string $id */ $id = $this->getKey(); $snapshot = $this->withoutRelation('tokenable'); - $ttl = config('sanctum.cache.ttl'); + $ttl = config()->integer('sanctum.cache.ttl', Sanctum::DEFAULT_CACHE_TTL); $this->settleCacheMutation( fn () => static::getCache()->put(static::getCacheKey($id), $snapshot, $ttl) @@ -326,7 +329,7 @@ protected function settleCacheMutation(Closure $callback): void protected static function getCache(): CacheRepository { $cacheManager = Container::getInstance()->make('cache'); - $store = config('sanctum.cache.store'); + $store = config()->get('sanctum.cache.store'); return $store !== null && $store !== '' ? $cacheManager->store($store) @@ -338,7 +341,7 @@ protected static function getCache(): CacheRepository */ protected static function getCacheKey(int|string $tokenId): string { - $prefix = config('sanctum.cache.prefix'); + $prefix = config()->string('sanctum.cache.prefix', 'sanctum'); return "{$prefix}:{$tokenId}"; } } diff --git a/src/sanctum/src/PersonalAccessTokenRelation.php b/src/sanctum/src/PersonalAccessTokenRelation.php index 0e5d0f471..7e424126d 100644 --- a/src/sanctum/src/PersonalAccessTokenRelation.php +++ b/src/sanctum/src/PersonalAccessTokenRelation.php @@ -19,7 +19,7 @@ class PersonalAccessTokenRelation extends MorphMany */ public function delete(): mixed { - if (! config('sanctum.cache.enabled')) { + if (! config()->boolean('sanctum.cache.enabled', false)) { return $this->getQuery()->delete(); } diff --git a/src/sanctum/src/Sanctum.php b/src/sanctum/src/Sanctum.php index fcc38f045..1a15c50a0 100644 --- a/src/sanctum/src/Sanctum.php +++ b/src/sanctum/src/Sanctum.php @@ -15,6 +15,10 @@ class Sanctum { + public const int DEFAULT_CACHE_TTL = 300; + + public const int DEFAULT_LAST_USED_AT_UPDATE_INTERVAL = 300; + /** @var class-string */ protected const string DEFAULT_PERSONAL_ACCESS_TOKEN_MODEL = PersonalAccessToken::class; diff --git a/src/sanctum/src/SanctumServiceProvider.php b/src/sanctum/src/SanctumServiceProvider.php index 7e37e5f8f..f9145ec58 100644 --- a/src/sanctum/src/SanctumServiceProvider.php +++ b/src/sanctum/src/SanctumServiceProvider.php @@ -45,7 +45,7 @@ public function boot(): void $config = $this->app->make(ConfigRepository::class); $cache->allowSerializableClassesUsing(function () use ($config): array { - if (! $config->boolean('sanctum.cache.enabled')) { + if (! $config->boolean('sanctum.cache.enabled', false)) { return []; } @@ -119,7 +119,7 @@ public function boot(): void */ private function validateCacheConfiguration(CacheManager $cache, ConfigRepository $config): void { - if (! $config->boolean('sanctum.cache.enabled')) { + if (! $config->boolean('sanctum.cache.enabled', false)) { return; } @@ -129,15 +129,18 @@ private function validateCacheConfiguration(CacheManager $cache, ConfigRepositor throw new InvalidArgumentException('Sanctum cache store must be a string or null.'); } - $ttl = $config->get('sanctum.cache.ttl'); + $ttl = $config->integer('sanctum.cache.ttl', Sanctum::DEFAULT_CACHE_TTL); - if (! is_int($ttl) || $ttl <= 0) { + if ($ttl <= 0) { throw new InvalidArgumentException('Sanctum cache TTL must be a positive integer.'); } - $interval = $config->get('sanctum.cache.last_used_at_update_interval'); + $interval = $config->integer( + 'sanctum.cache.last_used_at_update_interval', + Sanctum::DEFAULT_LAST_USED_AT_UPDATE_INTERVAL, + ); - if (! is_int($interval) || $interval < 0) { + if ($interval < 0) { throw new InvalidArgumentException( 'Sanctum cache last_used_at_update_interval must be a non-negative integer.' ); @@ -160,11 +163,11 @@ protected function defineRoutes(): void $config = $this->app->make(ConfigRepository::class); - if (! $config->boolean('sanctum.routes', true)) { + if (! $config->boolean('sanctum.routes')) { return; } - Route::group(['prefix' => $config->string('sanctum.prefix', 'sanctum')], function (): void { + Route::group(['prefix' => $config->string('sanctum.prefix')], function (): void { Route::get('/csrf-cookie', [CsrfCookieController::class, 'show']) ->middleware('web') ->name('sanctum.csrf-cookie'); @@ -214,7 +217,7 @@ protected function createGuard( return new SanctumGuard( name: $name, - provider: $authManager->createUserProvider($config['provider'] ?? null), + provider: $authManager->createUserProvider($config['provider']), app: $app, sessionGuards: $sessionGuards, events: $app->bound('events') ? $app->make('events') : null, diff --git a/src/scout/config/scout.php b/src/scout/config/scout.php index da6fc062e..f95a5f508 100644 --- a/src/scout/config/scout.php +++ b/src/scout/config/scout.php @@ -43,12 +43,13 @@ | By default, Hypervel Scout uses Coroutine::defer() which executes | indexing at coroutine exit (in HTTP requests, typically after the | response is emitted). Set 'enabled' to true to use - | the queue system instead for durability and retries. + | the queue system instead for durability and retries. Omitting the + | enabled member keeps deferred, non-queued indexing. | */ 'queue' => [ - 'enabled' => env('SCOUT_QUEUE', false), + 'enabled' => (bool) env('SCOUT_QUEUE', false), 'connection' => env('SCOUT_QUEUE_CONNECTION'), 'queue' => env('SCOUT_QUEUE_NAME'), ], @@ -90,6 +91,7 @@ | These options allow you to control the maximum chunk size when you are | mass importing data into the search engine. This allows you to fine | tune each of these chunk sizes based on the power of the servers. + | Omitted members use a chunk size of 500. | */ @@ -110,7 +112,7 @@ | */ - 'command_concurrency' => env('SCOUT_COMMAND_CONCURRENCY', 50), + 'command_concurrency' => (int) env('SCOUT_COMMAND_CONCURRENCY', 50), /* |-------------------------------------------------------------------------- @@ -138,7 +140,7 @@ | */ - 'identify' => env('SCOUT_IDENTIFY', false), + 'identify' => (bool) env('SCOUT_IDENTIFY', false), /* |-------------------------------------------------------------------------- @@ -148,12 +150,17 @@ | Here you may configure your Algolia settings. Algolia is a cloud hosted | search engine which works great with Scout out of the box. Just plug | in your application ID and admin API key to get started searching. + | Timeout values are measured in seconds; null leaves the corresponding + | Algolia SDK default unchanged. | */ 'algolia' => [ 'id' => env('ALGOLIA_APP_ID', ''), 'secret' => env('ALGOLIA_SECRET', ''), + 'connect_timeout' => null, + 'read_timeout' => null, + 'write_timeout' => null, 'index-settings' => [ // Per-index settings can be defined here: // 'users' => [ @@ -171,6 +178,7 @@ | Here you may configure your Meilisearch settings. Meilisearch is an open | source search engine with minimal configuration. Below, you can state | the host and key information for your own Meilisearch installation. + | Omitted host and retry members use the values shown below. | | See: https://www.meilisearch.com/docs/learn/configuration/instance_options | @@ -223,12 +231,12 @@ 'path' => env('TYPESENSE_PATH', ''), 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), ], - 'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2), - 'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30), - 'num_retries' => env('TYPESENSE_NUM_RETRIES', 3), - 'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1), + 'connection_timeout_seconds' => (int) env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2), + 'healthcheck_interval_seconds' => (int) env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30), + 'num_retries' => (int) env('TYPESENSE_NUM_RETRIES', 3), + 'retry_interval_seconds' => (int) env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1), ], - // 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000), + // 'max_total_results' => (int) env('TYPESENSE_MAX_TOTAL_RESULTS', 1000), 'model-settings' => [ // Per-model settings can be defined here: // App\Models\User::class => [ diff --git a/src/scout/src/Console/DeleteAllIndexesCommand.php b/src/scout/src/Console/DeleteAllIndexesCommand.php index bc8d9d2dc..cf6fd4bf6 100644 --- a/src/scout/src/Console/DeleteAllIndexesCommand.php +++ b/src/scout/src/Console/DeleteAllIndexesCommand.php @@ -35,7 +35,7 @@ public function handle(EngineManager $manager, Repository $config): int // Gate safety first, before resolving the engine. If prefix is empty // and --force isn't set, we refuse without ever instantiating the // driver's underlying client. - $prefix = $config->string('scout.prefix', ''); + $prefix = $config->string('scout.prefix'); $force = (bool) $this->option('force'); if ($prefix === '' && ! $force) { diff --git a/src/scout/src/Console/DeleteIndexCommand.php b/src/scout/src/Console/DeleteIndexCommand.php index a644618cf..9d3898e4a 100644 --- a/src/scout/src/Console/DeleteIndexCommand.php +++ b/src/scout/src/Console/DeleteIndexCommand.php @@ -50,7 +50,7 @@ protected function indexName(string $name, Repository $config): string return (new $name)->indexableAs(); } - $prefix = $config->string('scout.prefix', ''); + $prefix = $config->string('scout.prefix'); return ! Str::startsWith($name, $prefix) ? $prefix . $name : $name; } diff --git a/src/scout/src/Console/IndexCommand.php b/src/scout/src/Console/IndexCommand.php index d757f293c..cebb6556c 100644 --- a/src/scout/src/Console/IndexCommand.php +++ b/src/scout/src/Console/IndexCommand.php @@ -69,7 +69,7 @@ public function handle(EngineManager $manager, Repository $config): int ?? []; if ($model !== null - && $config->boolean('scout.soft_delete', false) + && $config->boolean('scout.soft_delete') && in_array(SoftDeletes::class, class_uses_recursive($model), true)) { $settings = $engine->configureSoftDeleteFilter($settings); } @@ -109,7 +109,7 @@ protected function indexName(string $name, Repository $config): string return (new $name)->indexableAs(); } - $prefix = $config->string('scout.prefix', ''); + $prefix = $config->string('scout.prefix'); return ! Str::startsWith($name, $prefix) ? $prefix . $name : $name; } diff --git a/src/scout/src/Console/QueueImportCommand.php b/src/scout/src/Console/QueueImportCommand.php index 5f22f6c93..a89ec2312 100644 --- a/src/scout/src/Console/QueueImportCommand.php +++ b/src/scout/src/Console/QueueImportCommand.php @@ -11,6 +11,7 @@ use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Exceptions\ScoutException; use Hypervel\Scout\Jobs\MakeRangeSearchable; +use Hypervel\Scout\Scout; use Symfony\Component\Console\Attribute\AsCommand; /** @@ -49,7 +50,10 @@ public function handle(Repository $config): int /** @var Model&SearchableInterface $model */ $model = new $class; - $chunk = max(1, (int) ($this->option('chunk') ?? $config->integer('scout.chunk.searchable', 500))); + $chunk = max(1, (int) ($this->option('chunk') ?? $config->integer( + 'scout.chunk.searchable', + Scout::DEFAULT_CHUNK_SIZE, + ))); $queueName = $this->option('queue') ?? $model->syncWithSearchUsingQueue(); $connection = $model->syncWithSearchUsing(); $order = (string) $this->option('order'); diff --git a/src/scout/src/Console/SyncIndexSettingsCommand.php b/src/scout/src/Console/SyncIndexSettingsCommand.php index 0d721836c..4e337b385 100644 --- a/src/scout/src/Console/SyncIndexSettingsCommand.php +++ b/src/scout/src/Console/SyncIndexSettingsCommand.php @@ -37,7 +37,7 @@ class SyncIndexSettingsCommand extends Command public function handle(EngineManager $manager, Repository $config): int { $driver = $this->option('driver'); - $driver = $driver === null || $driver === '' ? $config->string('scout.driver') : $driver; + $driver = $driver === null || $driver === '' ? $manager->getDefaultDriver() : $driver; $engine = $manager->engine($driver); @@ -68,7 +68,7 @@ public function handle(EngineManager $manager, Repository $config): int } if ($model !== null - && $config->boolean('scout.soft_delete', false) + && $config->boolean('scout.soft_delete') && in_array(SoftDeletes::class, class_uses_recursive($model), true)) { $settings = $engine->configureSoftDeleteFilter($settings); } @@ -92,7 +92,7 @@ protected function indexName(string $name, Repository $config): string return (new $name)->indexableAs(); } - $prefix = $config->string('scout.prefix', ''); + $prefix = $config->string('scout.prefix'); return ! Str::startsWith($name, $prefix) ? $prefix . $name : $name; } diff --git a/src/scout/src/EngineManager.php b/src/scout/src/EngineManager.php index 921774842..3c6cef32e 100644 --- a/src/scout/src/EngineManager.php +++ b/src/scout/src/EngineManager.php @@ -7,6 +7,7 @@ use Algolia\AlgoliaSearch\Algolia; use Algolia\AlgoliaSearch\Api\SearchClient as AlgoliaSearchClient; use Closure; +use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Container\Container; use Hypervel\Scout\Engines\AlgoliaEngine; use Hypervel\Scout\Engines\CollectionEngine; @@ -98,10 +99,13 @@ public function createAlgoliaDriver(): AlgoliaEngine { $this->ensureAlgoliaClientIsInstalled(); + /** @var Repository $config */ + $config = $this->container->make('config'); + return new AlgoliaEngine( $this->container->make(AlgoliaSearchClient::class), - $this->getConfig('soft_delete', false), - $this->getConfig('identify', false), + $config->boolean('scout.soft_delete'), + $config->boolean('scout.identify'), ); } @@ -128,9 +132,12 @@ public function createMeilisearchDriver(): MeilisearchEngine { $this->ensureMeilisearchClientIsInstalled(); + /** @var Repository $config */ + $config = $this->container->make('config'); + return new MeilisearchEngine( $this->container->make(MeilisearchClient::class), - $this->getConfig('soft_delete', false) + $config->boolean('scout.soft_delete') ); } @@ -159,9 +166,12 @@ public function createTypesenseDriver(): TypesenseEngine { $this->ensureTypesenseClientIsInstalled(); + /** @var Repository $config */ + $config = $this->container->make('config'); + return new TypesenseEngine( $this->container->make(TypesenseClient::class), - (int) $this->getConfig('typesense.max_total_results', 1000) + $config->integer('scout.typesense.max_total_results', 1000) ); } diff --git a/src/scout/src/Engines/CollectionEngine.php b/src/scout/src/Engines/CollectionEngine.php index e979fd3f9..89e4faf1a 100644 --- a/src/scout/src/Engines/CollectionEngine.php +++ b/src/scout/src/Engines/CollectionEngine.php @@ -4,7 +4,6 @@ namespace Hypervel\Scout\Engines; -use Hypervel\Container\Container; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -179,7 +178,7 @@ protected function ensureSoftDeletesAreHandled(Builder $builder, EloquentBuilder } if (in_array(SoftDeletes::class, class_uses_recursive(get_class($builder->model)), true) - && $this->getScoutConfig('soft_delete', false) + && config()->boolean('scout.soft_delete') ) { /* @phpstan-ignore method.notFound (SoftDeletingScope adds this method) */ return $query->withTrashed(); @@ -291,14 +290,4 @@ public function deleteIndex(string $name): mixed { return null; } - - /** - * Get a Scout configuration value. - */ - protected function getScoutConfig(string $key, mixed $default = null): mixed - { - return Container::getInstance() - ->make('config') - ->get("scout.{$key}", $default); - } } diff --git a/src/scout/src/Engines/DatabaseEngine.php b/src/scout/src/Engines/DatabaseEngine.php index 959ed1e42..0ce937c4a 100644 --- a/src/scout/src/Engines/DatabaseEngine.php +++ b/src/scout/src/Engines/DatabaseEngine.php @@ -4,7 +4,6 @@ namespace Hypervel\Scout\Engines; -use Hypervel\Container\Container; use Hypervel\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; @@ -345,7 +344,7 @@ class_uses_recursive(get_class($builder->model)), true ); - if ($usesSoftDeletes && $this->getConfig('soft_delete', false)) { + if ($usesSoftDeletes && config()->boolean('scout.soft_delete')) { /* @phpstan-ignore method.notFound (SoftDeletes adds this method via global scope) */ return $query->withTrashed(); } @@ -521,14 +520,4 @@ public function deleteIndex(string $name): mixed // No-op: The database table is the index. return null; } - - /** - * Get a Scout configuration value. - */ - protected function getConfig(string $key, mixed $default = null): mixed - { - return Container::getInstance() - ->make('config') - ->get("scout.{$key}", $default); - } } diff --git a/src/scout/src/Engines/TypesenseEngine.php b/src/scout/src/Engines/TypesenseEngine.php index 5f6c0c8cb..123d44960 100644 --- a/src/scout/src/Engines/TypesenseEngine.php +++ b/src/scout/src/Engines/TypesenseEngine.php @@ -61,7 +61,7 @@ public function update(EloquentCollection $models): void /** @var EloquentCollection $models */ $firstModel = $models->first(); - if ($this->usesSoftDelete($firstModel) && $this->getConfig('soft_delete', false)) { + if ($this->usesSoftDelete($firstModel) && config()->boolean('scout.soft_delete')) { $models->each->pushSoftDeleteMetadata(); } diff --git a/src/scout/src/ModelObserver.php b/src/scout/src/ModelObserver.php index 461c12dbe..da8ac02a9 100644 --- a/src/scout/src/ModelObserver.php +++ b/src/scout/src/ModelObserver.php @@ -45,8 +45,8 @@ class ModelObserver */ public function __construct() { - $this->afterCommit = Config::boolean('scout.after_commit', false); - $this->usingSoftDeletes = Config::boolean('scout.soft_delete', false); + $this->afterCommit = Config::boolean('scout.after_commit'); + $this->usingSoftDeletes = Config::boolean('scout.soft_delete'); } /** diff --git a/src/scout/src/Scout.php b/src/scout/src/Scout.php index fed292aa8..a9ddf55db 100644 --- a/src/scout/src/Scout.php +++ b/src/scout/src/Scout.php @@ -36,6 +36,11 @@ class Scout */ protected const string DEFAULT_REMOVE_FROM_SEARCH_JOB = RemoveFromSearch::class; + /** + * The default number of models processed in each search indexing chunk. + */ + public const int DEFAULT_CHUNK_SIZE = 500; + /** * Coroutine-local context key indicating that scout:import is currently running. * diff --git a/src/scout/src/ScoutServiceProvider.php b/src/scout/src/ScoutServiceProvider.php index 8da313689..e879b21c4 100644 --- a/src/scout/src/ScoutServiceProvider.php +++ b/src/scout/src/ScoutServiceProvider.php @@ -166,7 +166,7 @@ protected function registerTypesenseClient(): void { $this->app->singleton(TypesenseClient::class, function () { $config = $this->app->make('config'); - $settings = $config->array('scout.typesense.client-settings', []); + $settings = $config->array('scout.typesense.client-settings'); // Explicitly inject Guzzle as the HTTP client so Typesense never // falls back to PSR-18 auto-discovery, which may resolve to diff --git a/src/scout/src/Searchable.php b/src/scout/src/Searchable.php index 2619b12d5..a69735ea6 100644 --- a/src/scout/src/Searchable.php +++ b/src/scout/src/Searchable.php @@ -95,7 +95,7 @@ public function registerSearchableMacros(): void HasManyThrough::macro('searchable', function (?int $chunk = null): void { /** @var HasManyThrough $this */ - $chunkSize = $chunk ?? config('scout.chunk.searchable', 500); + $chunkSize = $chunk ?? config()->integer('scout.chunk.searchable', Scout::DEFAULT_CHUNK_SIZE); $this->chunkById($chunkSize, function (Collection $models): void { /** @var Collection $models */ @@ -112,7 +112,7 @@ public function registerSearchableMacros(): void HasManyThrough::macro('unsearchable', function (?int $chunk = null): void { /** @var HasManyThrough $this */ - $chunkSize = $chunk ?? config('scout.chunk.unsearchable', 500); + $chunkSize = $chunk ?? config()->integer('scout.chunk.unsearchable', Scout::DEFAULT_CHUNK_SIZE); $this->chunkById($chunkSize, function (Collection $models): void { /** @var Collection $models */ @@ -135,13 +135,13 @@ public function queueMakeSearchable(Collection $models): void return; } - if (! Scout::isImporting() && static::getScoutConfig('queue.enabled', false)) { + if (! Scout::isImporting() && config()->boolean('scout.queue.enabled', false)) { $jobClass = Scout::$makeSearchableJob; $pendingDispatch = $jobClass::dispatch($models) ->onConnection($models->first()->syncWithSearchUsing()) ->onQueue($models->first()->syncWithSearchUsingQueue()); - if (static::getScoutConfig('after_commit', false)) { + if (config()->boolean('scout.after_commit')) { $pendingDispatch->afterCommit(); } @@ -180,13 +180,13 @@ public function queueRemoveFromSearch(Collection $models): void return; } - if (! Scout::isImporting() && static::getScoutConfig('queue.enabled', false)) { + if (! Scout::isImporting() && config()->boolean('scout.queue.enabled', false)) { $jobClass = Scout::$removeFromSearchJob; $pendingDispatch = $jobClass::dispatch($models) ->onConnection($models->first()->syncWithSearchUsing()) ->onQueue($models->first()->syncWithSearchUsingQueue()); - if (static::getScoutConfig('after_commit', false)) { + if (config()->boolean('scout.after_commit')) { $pendingDispatch->afterCommit(); } @@ -242,7 +242,7 @@ public static function search(string $query = '', ?Closure $callback = null): Bu 'model' => new static, 'query' => $query, 'callback' => $callback, - 'softDelete' => static::usesSoftDelete() && static::getScoutConfig('soft_delete', false), + 'softDelete' => static::usesSoftDelete() && config()->boolean('scout.soft_delete'), ]); } @@ -260,7 +260,7 @@ public static function makeAllSearchable(?int $chunk = null): void public static function makeAllSearchableQuery(): EloquentBuilder { $self = new static; - $softDelete = static::usesSoftDelete() && static::getScoutConfig('soft_delete', false); + $softDelete = static::usesSoftDelete() && config()->boolean('scout.soft_delete'); return $self->newQuery() ->when(true, fn ($query) => $self->makeAllSearchableUsing($query)) @@ -430,7 +430,7 @@ public static function withoutSyncingToSearch(callable $callback): mixed */ public function searchableAs(): string { - return static::getScoutConfig('prefix', '') . $this->getTable(); + return config()->string('scout.prefix') . $this->getTable(); } /** @@ -462,7 +462,7 @@ public function searchableUsing(): Engine */ public function syncWithSearchUsing(): ?string { - return static::getScoutConfig('queue.connection'); + return config('scout.queue.connection'); } /** @@ -470,7 +470,7 @@ public function syncWithSearchUsing(): ?string */ public function syncWithSearchUsingQueue(): ?string { - return static::getScoutConfig('queue.queue'); + return config('scout.queue.queue'); } /** @@ -547,7 +547,7 @@ protected static function dispatchSearchableJob(callable $job): void if (! $runner instanceof ConcurrentImportRunner) { $runner = new ConcurrentImportRunner( - (int) static::getScoutConfig('command_concurrency', 50) + config()->integer('scout.command_concurrency') ); CoroutineContext::set(self::SCOUT_RUNNER_CONTEXT_KEY, $runner); } @@ -586,14 +586,4 @@ protected static function usesSoftDelete(): bool { return in_array(SoftDeletes::class, class_uses_recursive(static::class), true); } - - /** - * Get a Scout configuration value. - */ - protected static function getScoutConfig(string $key, mixed $default = null): mixed - { - return Container::getInstance() - ->make('config') - ->get("scout.{$key}", $default); - } } diff --git a/src/scout/src/SearchableScope.php b/src/scout/src/SearchableScope.php index bbbc52dd6..de4748007 100644 --- a/src/scout/src/SearchableScope.php +++ b/src/scout/src/SearchableScope.php @@ -37,7 +37,7 @@ public function extend(EloquentBuilder $builder): void /** @var Model&SearchableInterface $model */ $model = $builder->getModel(); $scoutKeyName = $model->getScoutKeyName(); - $chunkSize = $chunk ?? config('scout.chunk.searchable', 500); + $chunkSize = $chunk ?? config()->integer('scout.chunk.searchable', Scout::DEFAULT_CHUNK_SIZE); $builder->chunkById($chunkSize, function (Collection $models) { /** @var EloquentCollection $models */ @@ -59,7 +59,7 @@ public function extend(EloquentBuilder $builder): void /** @var Model&SearchableInterface $model */ $model = $builder->getModel(); $scoutKeyName = $model->getScoutKeyName(); - $chunkSize = $chunk ?? config('scout.chunk.unsearchable', 500); + $chunkSize = $chunk ?? config()->integer('scout.chunk.unsearchable', Scout::DEFAULT_CHUNK_SIZE); $builder->chunkById($chunkSize, function (Collection $models) { /** @var EloquentCollection $models */ diff --git a/src/sentry/config/sentry.php b/src/sentry/config/sentry.php index b048b582a..15352928d 100644 --- a/src/sentry/config/sentry.php +++ b/src/sentry/config/sentry.php @@ -20,10 +20,18 @@ * * @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/ */ +$organizationId = env('SENTRY_ORG_ID'); +$sampleRate = env('SENTRY_SAMPLE_RATE'); +$tracesSampleRate = env('SENTRY_TRACES_SAMPLE_RATE'); +$profilesSampleRate = env('SENTRY_PROFILES_SAMPLE_RATE'); +$logFlushThreshold = env('SENTRY_LOG_FLUSH_THRESHOLD'); + return [ // @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/ 'dsn' => env('SENTRY_HYPERVEL_DSN', env('SENTRY_DSN')), + // Set to true to use the default Spotlight endpoint, a URL string to use + // a custom endpoint, or false to disable Spotlight. // @see https://spotlightjs.com/ 'spotlight' => env('SENTRY_SPOTLIGHT', false), @@ -38,34 +46,34 @@ 'environment' => env('SENTRY_ENVIRONMENT'), // Override the organization ID used for trace continuation checks. - 'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'), + 'org_id' => $organizationId === null ? null : (int) $organizationId, // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate - 'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'), + 'sample_rate' => $sampleRate === null ? 1.0 : (float) $sampleRate, // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate - 'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'), + 'traces_sample_rate' => $tracesSampleRate === null ? null : (float) $tracesSampleRate, // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate - 'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'), + 'profiles_sample_rate' => $profilesSampleRate === null ? null : (float) $profilesSampleRate, // Only continue incoming traces when the organization IDs are compatible with this SDK instance. - 'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false), + 'strict_trace_continuation' => (bool) env('SENTRY_STRICT_TRACE_CONTINUATION', false), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs - 'enable_logs' => env('SENTRY_ENABLE_LOGS', false), + 'enable_logs' => (bool) env('SENTRY_ENABLE_LOGS', false), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics - 'enable_metrics' => env('SENTRY_ENABLE_METRICS', true), + 'enable_metrics' => (bool) env('SENTRY_ENABLE_METRICS', true), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold - 'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'), + 'log_flush_threshold' => $logFlushThreshold === null ? null : (int) $logFlushThreshold, // The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel - 'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))), + 'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('LOG_LEVEL', 'debug')), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii - 'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false), + 'send_default_pii' => (bool) env('SENTRY_SEND_DEFAULT_PII', false), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions // 'ignore_exceptions' => [], @@ -79,76 +87,84 @@ // Breadcrumb specific configuration 'breadcrumbs' => [ // Capture log messages as breadcrumbs - 'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true), + 'logs' => (bool) env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true), + + // Capture cache events (hits, writes etc.) as breadcrumbs. + // When Sentry is active, this enables repository events for every configured cache store. + 'cache' => (bool) env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true), - // Capture cache events (hits, writes etc.) as breadcrumbs - 'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true), + // Capture filesystem operations as breadcrumbs + 'storage' => (bool) env('SENTRY_BREADCRUMBS_STORAGE_ENABLED', true), // Capture SQL queries as breadcrumbs - 'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true), + 'sql_queries' => (bool) env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true), // Capture SQL query bindings (parameters) in SQL query breadcrumbs - 'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false), + 'sql_bindings' => (bool) env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false), // Capture SQL transactions (begin, commit, rollbacks) as breadcrumbs - 'sql_transactions' => env('SENTRY_BREADCRUMBS_SQL_TRANSACTIONS_ENABLED', true), + 'sql_transactions' => (bool) env('SENTRY_BREADCRUMBS_SQL_TRANSACTIONS_ENABLED', true), // Capture queue job information as breadcrumbs - 'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true), + 'queue_info' => (bool) env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true), // Capture command information as breadcrumbs - 'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true), + 'command_info' => (bool) env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true), // Capture HTTP client request information as breadcrumbs - 'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), + 'http_client_requests' => (bool) env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), // Capture send notifications as breadcrumbs - 'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), + 'notifications' => (bool) env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), ], // Performance monitoring specific configuration 'tracing' => [ // Trace queue jobs as their own transactions (this enables tracing for queue jobs) - 'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true), + 'queue_job_transactions' => (bool) env('SENTRY_TRACE_QUEUE_ENABLED', true), // Capture queue jobs as spans when executed on the sync driver - 'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true), + 'queue_jobs' => (bool) env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true), // Capture SQL queries as spans - 'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true), + 'sql_queries' => (bool) env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true), // Capture SQL query bindings (parameters) in SQL query spans - 'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false), + 'sql_bindings' => (bool) env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false), // Capture where the SQL query originated from on the SQL query spans - 'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), + 'sql_origin' => (bool) env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), // Define a threshold in milliseconds for SQL queries to resolve their origin - 'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), + 'sql_origin_threshold_ms' => (int) env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), // Capture views rendered as spans - 'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true), + 'views' => (bool) env('SENTRY_TRACE_VIEWS_ENABLED', true), // Capture HTTP client requests as spans - 'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), + 'http_client_requests' => (bool) env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture cache events (hits, writes etc.) as spans. + // When Sentry is active, this enables repository events for every configured cache store. + 'cache' => (bool) env('SENTRY_TRACE_CACHE_ENABLED', true), - // Capture cache events (hits, writes etc.) as spans - 'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true), + // Capture filesystem operations as spans + 'storage' => (bool) env('SENTRY_TRACE_STORAGE_ENABLED', true), // Capture Redis operations as spans (this enables Redis events in Hypervel) - 'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false), + 'redis_commands' => (bool) env('SENTRY_TRACE_REDIS_COMMANDS', false), // Capture where the Redis command originated from on the Redis command spans - 'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), + 'redis_origin' => (bool) env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), // Capture send notifications as spans - 'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), + 'notifications' => (bool) env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), // Enable tracing for requests without a matching route (404's) - 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), + 'missing_routes' => (bool) env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), // Continue the trace through after-response work before finishing the transaction - 'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), + 'continue_after_response' => (bool) env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), ], /* diff --git a/src/sentry/src/Aspects/GuzzleHttpClientAspect.php b/src/sentry/src/Aspects/GuzzleHttpClientAspect.php index 25e7943cf..8abd8b1d6 100644 --- a/src/sentry/src/Aspects/GuzzleHttpClientAspect.php +++ b/src/sentry/src/Aspects/GuzzleHttpClientAspect.php @@ -6,11 +6,10 @@ use GuzzleHttp\Client; use GuzzleHttp\TransferStats; -use Hypervel\Contracts\Config\Repository; use Hypervel\Di\Aop\AbstractAspect; use Hypervel\Di\Aop\ProceedingJoinPoint; use Hypervel\Sentry\Integration; -use Hypervel\Sentry\SdkCapabilities; +use Hypervel\Sentry\SentryConfig; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\UriInterface; use Sentry\Breadcrumb; @@ -49,14 +48,14 @@ class GuzzleHttpClientAspect extends AbstractAspect /** * Create a new aspect instance. */ - public function __construct( - private readonly Repository $config, - SdkCapabilities $capabilities, - ) { - $this->tracingEnabled = $capabilities->canRecordSpans() - && $this->config->boolean('sentry.tracing.http_client_requests', true); - $this->breadcrumbsEnabled = $capabilities->canRecordBreadcrumbs() - && $this->config->boolean('sentry.breadcrumbs.http_client_requests', true); + public function __construct(SentryConfig $config) + { + $userConfig = $config->all(); + + $this->tracingEnabled = $config->canRecordSpans() + && $userConfig['tracing']['http_client_requests'] === true; + $this->breadcrumbsEnabled = $config->canRecordBreadcrumbs() + && $userConfig['breadcrumbs']['http_client_requests'] === true; } /** diff --git a/src/sentry/src/EventHandler.php b/src/sentry/src/EventHandler.php index 8beb9278d..50eb0384e 100644 --- a/src/sentry/src/EventHandler.php +++ b/src/sentry/src/EventHandler.php @@ -64,10 +64,12 @@ public function __construct( private readonly Container $container, array $config, ) { - $this->recordSqlQueries = ($config['breadcrumbs']['sql_queries'] ?? true) === true; - $this->recordSqlBindings = ($config['breadcrumbs']['sql_bindings'] ?? false) === true; - $this->recordSqlTransactions = ($config['breadcrumbs']['sql_transactions'] ?? true) === true; - $this->recordLogs = ($config['breadcrumbs']['logs'] ?? true) === true; + $breadcrumbs = $config['breadcrumbs']; + + $this->recordSqlQueries = $breadcrumbs['sql_queries'] === true; + $this->recordSqlBindings = $breadcrumbs['sql_bindings'] === true; + $this->recordSqlTransactions = $breadcrumbs['sql_transactions'] === true; + $this->recordLogs = $breadcrumbs['logs'] === true; } /** diff --git a/src/sentry/src/Features/CacheFeature.php b/src/sentry/src/Features/CacheFeature.php index c6fc9cfb3..2f17ad570 100644 --- a/src/sentry/src/Features/CacheFeature.php +++ b/src/sentry/src/Features/CacheFeature.php @@ -56,7 +56,9 @@ public function isApplicable(): bool public function onBoot(): void { $config = $this->container->make('config'); - $stores = array_keys($config->array('cache.stores', [])); + $stores = array_keys($config->array('cache.stores')); + // This method runs only for an active endpoint with cache telemetry enabled, + // which requires repository events even when a store explicitly disabled them. foreach ($stores as $store) { $config->set("cache.stores.{$store}.events", true); } diff --git a/src/sentry/src/Features/Feature.php b/src/sentry/src/Features/Feature.php index e5e35c944..7bbdb05ef 100644 --- a/src/sentry/src/Features/Feature.php +++ b/src/sentry/src/Features/Feature.php @@ -5,12 +5,9 @@ namespace Hypervel\Sentry\Features; use Hypervel\Contracts\Container\Container; -use Hypervel\Sentry\SdkCapabilities; +use Hypervel\Sentry\SentryConfig; use Sentry\SentrySdk; -/** - * @internal - */ abstract class Feature { /** @@ -92,7 +89,7 @@ public function bootInactive(): void */ protected function getUserConfig(): array { - return $this->container->make('config')->array('sentry', []); + return $this->container->make(SentryConfig::class)->all(); } /** @@ -155,7 +152,7 @@ protected function canRecordSpans(): bool } return $this->canRecordSpans = $this->container - ->make(SdkCapabilities::class) + ->make(SentryConfig::class) ->canRecordSpans(); } @@ -169,7 +166,7 @@ protected function canRecordBreadcrumbs(): bool } return $this->canRecordBreadcrumbs = $this->container - ->make(SdkCapabilities::class) + ->make(SentryConfig::class) ->canRecordBreadcrumbs(); } } diff --git a/src/sentry/src/Features/RedisFeature.php b/src/sentry/src/Features/RedisFeature.php index 2f3aedec9..051d2e35a 100644 --- a/src/sentry/src/Features/RedisFeature.php +++ b/src/sentry/src/Features/RedisFeature.php @@ -33,7 +33,7 @@ class RedisFeature extends Feature public function isApplicable(): bool { - return $this->isTracingFeatureEnabled('redis_commands'); + return $this->isTracingFeatureEnabled('redis_commands', false); } public function onBoot(): void @@ -92,7 +92,7 @@ private function recordCommand(CommandExecuted|CommandFailed $event): void 'db.system' => 'redis', 'db.statement' => $redisStatement, 'db.redis.connection' => $event->connectionName, - 'db.redis.database_index' => (int) ($config['database'] ?? 0), + 'db.redis.database_index' => $config['database'] ?? 0, 'db.redis.pool.name' => $event->connectionName, 'db.redis.pool.max' => $pool->getOption()->getMaxConnections(), 'db.redis.pool.max_idle_time' => $pool->getOption()->getMaxIdleTime(), diff --git a/src/sentry/src/Http/HypervelRequestFetcher.php b/src/sentry/src/Http/HypervelRequestFetcher.php index 5d19bc09a..6ff8e8609 100644 --- a/src/sentry/src/Http/HypervelRequestFetcher.php +++ b/src/sentry/src/Http/HypervelRequestFetcher.php @@ -43,7 +43,7 @@ public function fetchRequest(): ?ServerRequestInterface */ protected function filterCookies(array $cookies): array { - $forbiddenCookies = [config('session.cookie'), 'remember_*', 'XSRF-TOKEN']; + $forbiddenCookies = [config()->string('session.cookie'), 'remember_*', 'XSRF-TOKEN']; $filtered = []; foreach ($cookies as $key => $value) { diff --git a/src/sentry/src/SdkCapabilities.php b/src/sentry/src/SentryConfig.php similarity index 53% rename from src/sentry/src/SdkCapabilities.php rename to src/sentry/src/SentryConfig.php index d81056e1b..b3783685f 100644 --- a/src/sentry/src/SdkCapabilities.php +++ b/src/sentry/src/SentryConfig.php @@ -10,13 +10,45 @@ /** * @internal */ -class SdkCapabilities +class SentryConfig { + private const array BREADCRUMB_DEFAULTS = [ + 'logs' => true, + 'cache' => true, + 'storage' => true, + 'sql_queries' => true, + 'sql_bindings' => false, + 'sql_transactions' => true, + 'queue_info' => true, + 'command_info' => true, + 'http_client_requests' => true, + 'notifications' => true, + ]; + + private const array TRACING_DEFAULTS = [ + 'queue_job_transactions' => true, + 'queue_jobs' => true, + 'sql_queries' => true, + 'sql_bindings' => false, + 'sql_origin' => true, + 'sql_origin_threshold_ms' => 100, + 'views' => true, + 'http_client_requests' => true, + 'cache' => true, + 'storage' => true, + 'redis_commands' => false, + 'redis_origin' => true, + 'notifications' => true, + 'missing_routes' => false, + 'continue_after_response' => true, + ]; + /** - * Create a new SDK capability reader. + * Create a new Sentry configuration reader. */ public function __construct( private readonly Repository $config, + private readonly string $root, ) { } @@ -25,7 +57,7 @@ public function __construct( */ public function hasDsnSet(): bool { - return self::configHasDsn($this->userConfig()); + return self::configHasDsn($this->all()); } /** @@ -33,7 +65,7 @@ public function hasDsnSet(): bool */ public function hasSpotlightEnabled(): bool { - return self::configHasSpotlightEnabled($this->userConfig()); + return self::configHasSpotlightEnabled($this->all()); } /** @@ -41,13 +73,14 @@ public function hasSpotlightEnabled(): bool */ public function canRecordSpans(): bool { - $config = $this->userConfig(); + $config = $this->all(); $enableTracing = $config['enable_tracing'] ?? null; + $tracesSampleRate = $config['traces_sample_rate']; // Mirror Options::__construct()'s legacy enable_tracing default and Options::isTracingEnabled(). $tracingEnabled = $enableTracing === true || ($enableTracing !== false - && (($config['traces_sample_rate'] ?? null) !== null + && ($tracesSampleRate !== null || ($config['traces_sampler'] ?? null) !== null)); return self::configHasActiveEndpoint($config) && $tracingEnabled; @@ -58,7 +91,7 @@ public function canRecordSpans(): bool */ public function canRecordBreadcrumbs(): bool { - $config = $this->userConfig(); + $config = $this->all(); return self::configHasActiveEndpoint($config) && ($config['max_breadcrumbs'] ?? Options::DEFAULT_MAX_BREADCRUMBS) > 0; @@ -69,9 +102,15 @@ public function canRecordBreadcrumbs(): bool * * @return array */ - private function userConfig(): array + public function all(): array { - return $this->config->array('sentry', []); + $config = $this->config->array($this->root); + $config['breadcrumbs'] = $this->config->array($this->root . '.breadcrumbs', []) + + self::BREADCRUMB_DEFAULTS; + $config['tracing'] = $this->config->array($this->root . '.tracing', []) + + self::TRACING_DEFAULTS; + + return $config; } /** @@ -81,7 +120,9 @@ private function userConfig(): array */ private static function configHasDsn(array $config): bool { - return ! empty($config['dsn']); + $dsn = $config['dsn']; + + return ! empty($dsn); } /** @@ -91,9 +132,10 @@ private static function configHasDsn(array $config): bool */ private static function configHasSpotlightEnabled(array $config): bool { - $spotlight = $config['spotlight'] ?? false; + $spotlight = $config['spotlight']; - return $spotlight === true || (is_string($spotlight) && $spotlight !== ''); + // Match the SDK's disabled handling for the environment string '0'. + return $spotlight === true || (is_string($spotlight) && ! empty($spotlight)); } /** diff --git a/src/sentry/src/SentryServiceProvider.php b/src/sentry/src/SentryServiceProvider.php index 5498072bf..962373174 100644 --- a/src/sentry/src/SentryServiceProvider.php +++ b/src/sentry/src/SentryServiceProvider.php @@ -41,6 +41,7 @@ use Hypervel\View\Engines\EngineResolver; use Hypervel\View\Factory as ViewFactory; use InvalidArgumentException; +use LogicException; use Psr\Log\LoggerInterface; use RuntimeException; use Sentry\ClientBuilder; @@ -119,6 +120,19 @@ public function boot(): void */ public function register(): void { + if ($this->app->bound(SentryConfig::class)) { + throw new LogicException(sprintf( + 'Sentry provider [%s] cannot be registered because another Sentry provider is already registered. Add [hypervel/sentry] to [extra.hypervel.dont-discover] before registering a custom provider, or remove the custom provider.', + static::class, + )); + } + + $configRoot = static::$abstract; + $this->app->singleton( + SentryConfig::class, + fn () => new SentryConfig($this->app->make(ConfigRepository::class), $configRoot), + ); + $this->mergeConfigFrom(__DIR__ . '/../config/sentry.php', static::$abstract); $this->app->singleton(DebugFileLogger::class, function () { @@ -163,8 +177,10 @@ public function reloadConfiguration(): void */ protected function configureAndRegisterClient(): void { + $configRoot = static::$abstract; + // ClientBuilder — fresh per resolution so each Hub gets a properly configured builder - $this->app->bind(ClientBuilder::class, function () { + $this->app->bind(ClientBuilder::class, function () use ($configRoot) { $basePath = base_path(); $userConfig = $this->getUserConfig(); @@ -200,7 +216,7 @@ protected function configureAndRegisterClient(): void $clientBuilder->setSdkVersion(Version::getSdkVersion()); // Set the pooled transport for async sending via Swoole coroutines - $poolConfig = $this->app->make('config')->array('sentry.pool', []); + $poolConfig = $this->app->make('config')->array("{$configRoot}.pool"); $transport = new HttpPoolTransport( new Pool( $clientBuilder->getOptions(), @@ -245,7 +261,7 @@ protected function createClient(): ClientInterface $userConfig = $this->getUserConfig(); /** @var array|callable $userIntegrationOption */ - $userIntegrationOption = $userConfig['integrations'] ?? []; + $userIntegrationOption = $userConfig['integrations']; $userIntegrations = $this->resolveIntegrationsFromUserConfig( is_array($userIntegrationOption) ? $userIntegrationOption : [], @@ -339,11 +355,11 @@ protected function bindEvents(): void $handler->subscribe($dispatcher); - if (isset($userConfig['send_default_pii']) && $userConfig['send_default_pii'] !== false) { + if ($userConfig['send_default_pii'] === true) { $handler->subscribeAuthEvents($dispatcher); } - if (isset($userConfig['enable_logs']) && $userConfig['enable_logs'] === true) { + if ($userConfig['enable_logs'] === true) { $this->app->terminating(static function () { Logs::getInstance()->flush(); }); @@ -378,14 +394,15 @@ protected function registerMiddleware(): void */ protected function bootTracing(): void { - $tracingConfig = $this->getUserConfig()['tracing'] ?? []; + $tracingConfig = $this->getUserConfig()['tracing']; // Register the tracing middleware as scoped so each coroutine gets its own instance. // Per-request state ($transaction, $appSpan, $didRouteMatch) is isolated between concurrent requests. $this->app->scoped( TracingMiddleware::class, static fn () => new TracingMiddleware( - ($tracingConfig['continue_after_response'] ?? true) === true, + $tracingConfig['continue_after_response'] === true, + $tracingConfig['missing_routes'] === true, ), ); @@ -424,7 +441,7 @@ private function bindTracingEvents(array $tracingConfig): void */ private function bindViewEngine(array $tracingConfig): void { - if (($tracingConfig['views'] ?? true) !== true) { + if ($tracingConfig['views'] !== true) { return; } @@ -530,7 +547,7 @@ protected function registerCoroutineContextPropagation(): void */ protected function registerFeatures(): void { - $features = $this->app->make('config')->array('sentry.features', []); + $features = $this->app->make('config')->array(static::$abstract . '.features'); foreach ($features as $feature) { try { @@ -551,7 +568,7 @@ protected function bootFeatures(): void { $bootActive = $this->isActive(); - $features = $this->app->make('config')->array('sentry.features', []); + $features = $this->app->make('config')->array(static::$abstract . '.features'); foreach ($features as $feature) { try { @@ -592,12 +609,13 @@ private function reportFeatureFailure(string $feature, string $phase, Throwable protected function registerLogChannels(): void { $config = $this->app->make(ConfigRepository::class); + $configRoot = static::$abstract; // Derived config can depend on the worker environment, so replay the operation after config reload rather than its master result. $this->app->make(ConfigMutationTracker::class)->applyAndRecord( $config, - static function (ConfigRepository $config): void { - $logChannels = $config->array('logging.channels', []); + static function (ConfigRepository $config) use ($configRoot): void { + $logChannels = $config->array('logging.channels'); if (! array_key_exists('sentry', $logChannels)) { $config->set('logging.channels.sentry', [ @@ -608,7 +626,7 @@ static function (ConfigRepository $config): void { if (! array_key_exists('sentry_logs', $logChannels)) { $config->set('logging.channels.sentry_logs', [ 'driver' => 'sentry_logs', - 'level' => $config->string('sentry.logs_channel_level', 'debug'), + 'level' => $config->string("{$configRoot}.logs_channel_level"), ]); } }, @@ -621,7 +639,7 @@ static function (ConfigRepository $config): void { protected function registerPublishing(): void { $this->publishes([ - __DIR__ . '/../config/sentry.php' => config_path('sentry.php'), + __DIR__ . '/../config/sentry.php' => config_path(static::$abstract . '.php'), ], 'sentry-config'); } @@ -701,7 +719,7 @@ protected function isActive(): bool */ protected function hasDsnSet(): bool { - return $this->app->make(SdkCapabilities::class)->hasDsnSet(); + return $this->app->make(SentryConfig::class)->hasDsnSet(); } /** @@ -709,7 +727,7 @@ protected function hasDsnSet(): bool */ protected function hasSpotlightEnabled(): bool { - return $this->app->make(SdkCapabilities::class)->hasSpotlightEnabled(); + return $this->app->make(SentryConfig::class)->hasSpotlightEnabled(); } /** @@ -717,6 +735,6 @@ protected function hasSpotlightEnabled(): bool */ protected function getUserConfig(): array { - return $this->app->make('config')->array(static::$abstract); + return $this->app->make(SentryConfig::class)->all(); } } diff --git a/src/sentry/src/Tracing/EventHandler.php b/src/sentry/src/Tracing/EventHandler.php index c8b7cab2c..67ebae552 100644 --- a/src/sentry/src/Tracing/EventHandler.php +++ b/src/sentry/src/Tracing/EventHandler.php @@ -57,10 +57,10 @@ class EventHandler */ public function __construct(array $config) { - $this->traceSqlQueries = ($config['sql_queries'] ?? true) === true; - $this->traceSqlBindings = ($config['sql_bindings'] ?? false) === true; - $this->traceSqlQueryOrigin = ($config['sql_origin'] ?? true) === true; - $this->traceSqlQueryOriginThresholdMs = $config['sql_origin_threshold_ms'] ?? 100; + $this->traceSqlQueries = $config['sql_queries'] === true; + $this->traceSqlBindings = $config['sql_bindings'] === true; + $this->traceSqlQueryOrigin = $config['sql_origin'] === true; + $this->traceSqlQueryOriginThresholdMs = $config['sql_origin_threshold_ms']; } /** diff --git a/src/sentry/src/Tracing/Middleware.php b/src/sentry/src/Tracing/Middleware.php index 31d4b0c11..d507e2cb7 100644 --- a/src/sentry/src/Tracing/Middleware.php +++ b/src/sentry/src/Tracing/Middleware.php @@ -51,6 +51,7 @@ class Middleware */ public function __construct( private readonly bool $continueAfterResponse = true, + private readonly bool $traceMissingRoutes = false, ) { } @@ -288,7 +289,7 @@ private function internalSignalRouteWasMatched(): void */ private function shouldRouteBeIgnored(): bool { - return ! $this->didRouteMatch && config('sentry.tracing.missing_routes', false) === false; + return ! $this->didRouteMatch && ! $this->traceMissingRoutes; } /** diff --git a/src/server/src/Commands/ServerStartCommand.php b/src/server/src/Commands/ServerStartCommand.php index 5e2cd89d0..2c66668f8 100644 --- a/src/server/src/Commands/ServerStartCommand.php +++ b/src/server/src/Commands/ServerStartCommand.php @@ -87,7 +87,7 @@ protected function startServer(InputInterface $input): int throw new InvalidArgumentException('The serve port must be an integer between 1 and 65535.'); } - $servers = $serverConfig['servers'] ?? []; + $servers = $serverConfig['servers']; $httpServerIndex = null; foreach ($servers as $index => $server) { diff --git a/src/signal/src/SignalManager.php b/src/signal/src/SignalManager.php index 99b0dfeb6..819a199d8 100644 --- a/src/signal/src/SignalManager.php +++ b/src/signal/src/SignalManager.php @@ -189,7 +189,7 @@ protected function resolveHandlers(string $process): array */ protected function getQueue(): SplPriorityQueue { - $handlers = $this->config->array('signal.handlers', []); + $handlers = $this->config->array('signal.handlers'); $queue = new SplPriorityQueue; foreach ($handlers as $handler => $priority) { diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php index c4a89e748..41aea538a 100644 --- a/src/support/src/ServiceProvider.php +++ b/src/support/src/ServiceProvider.php @@ -241,11 +241,9 @@ protected function loadViewsFrom(array|string $path, string $namespace): void $this->callAfterResolving(ViewFactoryContract::class, function ($view) use ($path, $namespace) { $config = $this->app->make('config'); - if (is_array($viewPaths = $config->get('view.paths'))) { - foreach ($viewPaths as $viewPath) { - if (is_dir($appPath = $viewPath . '/vendor/' . $namespace)) { - $view->addNamespace($namespace, $appPath); - } + foreach ($config->array('view.paths') as $viewPath) { + if (is_dir($appPath = $viewPath . '/vendor/' . $namespace)) { + $view->addNamespace($namespace, $appPath); } } diff --git a/src/telescope/config/telescope.php b/src/telescope/config/telescope.php index e9792be7a..e2ca746ad 100644 --- a/src/telescope/config/telescope.php +++ b/src/telescope/config/telescope.php @@ -5,6 +5,8 @@ use Hypervel\Telescope\Http\Middleware\Authorize; use Hypervel\Telescope\Watchers; +$queueDelay = env('TELESCOPE_QUEUE_DELAY', 10); + return [ /* |-------------------------------------------------------------------------- @@ -17,7 +19,7 @@ | */ - 'enabled' => env('TELESCOPE_ENABLED', true), + 'enabled' => (bool) env('TELESCOPE_ENABLED', true), /* |-------------------------------------------------------------------------- @@ -25,8 +27,8 @@ |-------------------------------------------------------------------------- | | This is the subdomain where Telescope will be accessible from. If the - | setting is null, Telescope will reside under the same domain as the - | application. Otherwise, this value will be used as the subdomain. + | setting is omitted or null, Telescope will reside under the same domain + | as the application. Otherwise, this value will be used as the subdomain. | */ @@ -37,9 +39,9 @@ | Telescope Path |-------------------------------------------------------------------------- | - | This is the URI path where Telescope will be accessible from. Feel free - | to change this path to anything you like. Note that the URI will not - | affect the paths of its internal API that aren't exposed to users. + | This required value is the URI path prefix for Telescope's dashboard + | and API routes. Feel free to change it to any path your application + | and infrastructure expose. | */ @@ -50,9 +52,10 @@ | Telescope Storage Driver |-------------------------------------------------------------------------- | - | This configuration options determines the storage driver that will - | be used to store Telescope's data. In addition, you may set any - | custom options as needed by the particular driver you choose. + | This option determines the storage driver used for Telescope's data. + | The database connection identifies where its tables reside, while the + | chunk size controls how many entries are inserted in each batch. When + | omitted, the chunk size uses the database repository's default. | */ @@ -71,27 +74,28 @@ |-------------------------------------------------------------------------- | | This option determines whether Telescope storage should be deferred - | until the current coroutine finishes. + | until the current coroutine finishes. When omitted, storage is deferred. | */ - 'defer' => env('TELESCOPE_STORE_DEFER', true), + 'defer' => (bool) env('TELESCOPE_STORE_DEFER', true), /* |-------------------------------------------------------------------------- | Telescope Queue |-------------------------------------------------------------------------- | - | These options determine the queue connection and queue - | which will be used to process ProcessPendingUpdates jobs. This can - | be changed if you would prefer to use a non-default connection. + | These options determine the queue connection and queue which will be + | used to process ProcessPendingUpdates jobs. A null connection or queue + | uses the queue worker defaults, while a null or non-positive delay + | dispatches follow-up updates without a delay. | */ 'queue' => [ 'connection' => env('TELESCOPE_QUEUE_CONNECTION'), 'queue' => env('TELESCOPE_QUEUE'), - 'delay' => env('TELESCOPE_QUEUE_DELAY', 10), + 'delay' => $queueDelay === null ? null : (int) $queueDelay, ], /* @@ -99,9 +103,9 @@ | Telescope Route Middleware |-------------------------------------------------------------------------- | - | These middleware will be assigned to every Telescope route, giving you - | the chance to add your own middleware to this list or change any of - | the existing middleware. Or, you can simply stick with this list. + | These middleware are assigned to every Telescope route. The Authorize + | middleware enforces Telescope's access policy and should only be removed + | when it is replaced with equivalent protection. | */ @@ -115,9 +119,10 @@ | Allowed / Ignored Paths & Commands |-------------------------------------------------------------------------- | - | The following array lists the URI paths and Artisan commands that will - | not be watched by Telescope. In addition to this list, some Hypervel - | commands, like migrations and queue commands, are always ignored. + | A non-empty only-paths list limits recording to matching requests. The + | ignore-paths and ignore-commands lists add exclusions. Omitted lists are + | treated as empty, while some framework paths and commands are always + | ignored by Telescope. | */ @@ -143,36 +148,36 @@ */ 'watchers' => [ - Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true), + Watchers\BatchWatcher::class => (bool) env('TELESCOPE_BATCH_WATCHER', true), Watchers\CacheWatcher::class => [ - 'enabled' => env('TELESCOPE_CACHE_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_CACHE_WATCHER', true), 'hidden' => [], 'ignore' => [], ], Watchers\ClientRequestWatcher::class => [ - 'enabled' => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_CLIENT_REQUEST_WATCHER', true), 'ignore_hosts' => [], - 'request_size_limit' => env('TELESCOPE_HTTP_CLIENT_REQUEST_SIZE_LIMIT', 64), - 'response_size_limit' => env('TELESCOPE_HTTP_CLIENT_RESPONSE_SIZE_LIMIT', 64), + 'request_size_limit' => (int) env('TELESCOPE_HTTP_CLIENT_REQUEST_SIZE_LIMIT', 64), + 'response_size_limit' => (int) env('TELESCOPE_HTTP_CLIENT_RESPONSE_SIZE_LIMIT', 64), // When false (default), oversized payloads are replaced with "Purged By Telescope" // without reading or processing the body — the most performant option. When true, // the full body is read, sensitive fields are masked, and the result is truncated // to the size limit with a "(truncated...)" suffix, giving partial visibility at // the cost of additional memory and CPU for large payloads. - 'truncate_oversized' => env('TELESCOPE_HTTP_CLIENT_TRUNCATE_OVERSIZED', false), + 'truncate_oversized' => (bool) env('TELESCOPE_HTTP_CLIENT_TRUNCATE_OVERSIZED', false), ], Watchers\CommandWatcher::class => [ - 'enabled' => env('TELESCOPE_COMMAND_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_COMMAND_WATCHER', true), 'ignore' => [], ], Watchers\DumpWatcher::class => [ - 'enabled' => env('TELESCOPE_DUMP_WATCHER', true), - 'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false), + 'enabled' => (bool) env('TELESCOPE_DUMP_WATCHER', true), + 'always' => (bool) env('TELESCOPE_DUMP_WATCHER_ALWAYS', false), ], // Hypervel skips firing most events when no listeners are registered, @@ -180,50 +185,50 @@ // listener and is treated as a passive observer, so it will not cause // listener-guarded events to be fired just for Telescope. Watchers\EventWatcher::class => [ - 'enabled' => env('TELESCOPE_EVENT_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_EVENT_WATCHER', true), 'ignore' => [], ], - Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true), + Watchers\ExceptionWatcher::class => (bool) env('TELESCOPE_EXCEPTION_WATCHER', true), Watchers\GateWatcher::class => [ - 'enabled' => env('TELESCOPE_GATE_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_GATE_WATCHER', true), 'ignore_abilities' => [], 'ignore_packages' => true, 'ignore_paths' => [], ], - Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true), + Watchers\JobWatcher::class => (bool) env('TELESCOPE_JOB_WATCHER', true), Watchers\LogWatcher::class => [ - 'enabled' => env('TELESCOPE_LOG_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_LOG_WATCHER', true), 'level' => 'error', ], - Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true), + Watchers\MailWatcher::class => (bool) env('TELESCOPE_MAIL_WATCHER', true), Watchers\ModelWatcher::class => [ - 'enabled' => env('TELESCOPE_MODEL_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_MODEL_WATCHER', true), 'events' => ['eloquent.*'], 'hydrations' => true, ], - Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true), + Watchers\NotificationWatcher::class => (bool) env('TELESCOPE_NOTIFICATION_WATCHER', true), Watchers\QueryWatcher::class => [ - 'enabled' => env('TELESCOPE_QUERY_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_QUERY_WATCHER', true), 'ignore_packages' => true, 'ignore_paths' => [], 'slow' => 100, ], - Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true), + Watchers\RedisWatcher::class => (bool) env('TELESCOPE_REDIS_WATCHER', true), // Reverb — enabling message_received or message_sent adds a database write per // WebSocket message and should only be used for targeted debugging, not sustained // production use. Watchers\ReverbWatcher::class => [ - 'enabled' => env('TELESCOPE_REVERB_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_REVERB_WATCHER', true), 'events' => [ 'connection_established', 'connection_closed', @@ -233,17 +238,17 @@ // 'message_received', // 'message_sent', // Warning: fires per subscriber per broadcast — high volume. ], - 'message_size_limit' => env('TELESCOPE_REVERB_MESSAGE_SIZE_LIMIT', 64), // KB + 'message_size_limit' => (int) env('TELESCOPE_REVERB_MESSAGE_SIZE_LIMIT', 64), // KB ], Watchers\RequestWatcher::class => [ - 'enabled' => env('TELESCOPE_REQUEST_WATCHER', true), - 'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64), // KB + 'enabled' => (bool) env('TELESCOPE_REQUEST_WATCHER', true), + 'size_limit' => (int) env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64), // KB 'ignore_http_methods' => [], 'ignore_status_codes' => [], ], - Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true), - Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true), + Watchers\ScheduleWatcher::class => (bool) env('TELESCOPE_SCHEDULE_WATCHER', true), + Watchers\ViewWatcher::class => (bool) env('TELESCOPE_VIEW_WATCHER', true), ], ]; diff --git a/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php b/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php index 0ead78c55..bba73c623 100644 --- a/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php +++ b/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php @@ -10,9 +10,9 @@ /** * Get the migration connection name. */ - public function getConnection(): ?string + public function getConnection(): string { - return config('telescope.storage.database.connection'); + return config()->string('telescope.storage.database.connection'); } /** diff --git a/src/telescope/resources/views/layout.blade.php b/src/telescope/resources/views/layout.blade.php index 5ef066b94..df8a7e668 100644 --- a/src/telescope/resources/views/layout.blade.php +++ b/src/telescope/resources/views/layout.blade.php @@ -1,3 +1,4 @@ +@php($applicationName = config()->string('app.name')) @@ -10,7 +11,7 @@ - Telescope{{ config('app.name') ? ' - ' . config('app.name') : '' }} + Telescope{{ $applicationName ? ' - ' . $applicationName : '' }} @@ -35,7 +36,7 @@ -

Hypervel Telescope{{ config('app.name') ? ' - ' . config('app.name') : '' }}

+

Hypervel Telescope{{ $applicationName ? ' - ' . $applicationName : '' }}