Compare commits
29
Commits
91dc4707af
...
v2.0.0
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- config/livewire-material.php
|
||||
---
|
||||
|
||||
# Config
|
||||
|
||||
## Theme storage key is sealshare-theme
|
||||
The theme is stored in localStorage under `sealshare-theme` (default `system`). `mary-theme` stays in `theme.legacy_keys` so visitors from 1.x keep their choice once; don't rename the key or drop the legacy entry, or every returning visitor's theme resets.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- 'resources/css/material-scheme.*'
|
||||
---
|
||||
|
||||
# Css
|
||||
|
||||
## Regenerate the colour profiles, never hand-edit the scheme
|
||||
material-scheme.css and material-scheme.json are generated together by `php artisan material:scheme` (no seed) from the eight `profiles` in config/livewire-material.php — all Vibrant (chosen over Tonal Spot, which read washed out on indigo) except Graphite (Neutral); `profile` is the default, indigo. The JSON colours the Markdown mail theme and the fallback error pages and lists the profiles Admin settings offers and validates against, so a hand edit to the CSS alone leaves them out of step. Change the config and rerun the command; a profile only exists once generated. The admin's choice is the `color_profile` setting, read through `Scheme::resolveProfileUsing()` in AppServiceProvider.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Project Rules Index
|
||||
|
||||
Before planning or editing, find the row whose globs match the file's path and read that rule file.
|
||||
|
||||
| Applies to | Rule file |
|
||||
| --- | --- |
|
||||
| config/livewire-material.php | .ai/rules/config.md |
|
||||
| resources/css/material-scheme.* | .ai/rules/css.md |
|
||||
| resources/views/livewire/share-download.blade.php | .ai/rules/livewire.md |
|
||||
| tests/Screenshots/** | .ai/rules/screenshots.md |
|
||||
| website/** | .ai/rules/website.md |
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- resources/views/livewire/share-download.blade.php
|
||||
---
|
||||
|
||||
# Livewire
|
||||
|
||||
## No anchored components on the download page
|
||||
Recipients open share links on any phone, including iOS Safari below 18.4, where CSS anchor positioning is missing. Keep menus, tooltips, selects-as-menus, datepickers and other anchored/popover components off this page; use plain buttons with aria-label and native controls. tests/Browser/SealShareTest.php checks the page at 393px has no popovers.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- 'tests/Screenshots/**'
|
||||
---
|
||||
|
||||
# Screenshots
|
||||
|
||||
## Screenshots come from composer screenshots, before a release
|
||||
Run `composer screenshots` whenever the interface changes and before a release; it builds assets and runs tests/Screenshots (not part of any test suite or CI), publishing WebP files to website/img/screenshots. Demo data (DemoData) and the clock are fixed so runs are reproducible. Traps: Pest only starts its browser for a test whose body calls `visit(` after whitespace; Livewire's temporary-upload cleanup must stay off under the frozen clock or it deletes the selected files; the in-process server's random port is shown as https://files.example.com and the QR redrawn for it; upload_max_filesize/post_max_size are set to 4G by the script so the admin settings hint does not show the machine's PHP limit.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- 'website/**'
|
||||
---
|
||||
|
||||
# Website
|
||||
|
||||
## website/ is the live site, uploaded by hand
|
||||
website/ is a faithful copy of sealshare.nonameweb.ch (METANET hosting), hand-written HTML/CSS with no build step, uploaded wholesale when it changes. Colours in css/theme.css are copied from the indigo profile (the JSON's top-level light/dark) in resources/css/material-scheme.json — copy them again if indigo is regenerated differently; the site does not follow the admin's colour profile. The comparison tables are dated and every competitor value has a source from the product's own site, docs or repo; an unsourced value is "—", never a guess. Never call SealShare's encryption end-to-end (it encrypts at rest on the server). Nothing may load from another host except plausible.io. tests/Feature/WebsiteTest.php guards all of this.
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: infer-conventions
|
||||
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Infer Conventions
|
||||
|
||||
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
|
||||
|
||||
## Ground Rules (read before you start)
|
||||
|
||||
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
|
||||
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
|
||||
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
|
||||
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
|
||||
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
|
||||
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
|
||||
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
|
||||
|
||||
## Process
|
||||
|
||||
Each step ends on a checkable completion criterion. Do not advance until it holds.
|
||||
|
||||
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
|
||||
|
||||
### Step 0: Orient
|
||||
|
||||
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
|
||||
|
||||
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
|
||||
|
||||
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
|
||||
|
||||
### Step 1: Predefined sweep
|
||||
|
||||
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
|
||||
|
||||
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
|
||||
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
|
||||
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
|
||||
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
|
||||
- Tooling-owned or Already-recorded. Skip per the ground rules.
|
||||
|
||||
Done when: every applicable dimension carries exactly one of those verdicts.
|
||||
|
||||
### Step 2: Open-ended pass
|
||||
|
||||
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
|
||||
|
||||
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
|
||||
|
||||
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
|
||||
|
||||
### Step 3: Confirm
|
||||
|
||||
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
|
||||
|
||||
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
|
||||
|
||||
Done when: every candidate is approved, rejected, or (conflicts) decided.
|
||||
|
||||
### Step 4: Record
|
||||
|
||||
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
|
||||
|
||||
Record this:
|
||||
|
||||
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
|
||||
|
||||
Not this:
|
||||
|
||||
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
|
||||
|
||||
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
|
||||
|
||||
### Step 5: Summarize
|
||||
|
||||
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
|
||||
|
||||
## Glob mapping
|
||||
|
||||
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
|
||||
|
||||
Examples:
|
||||
|
||||
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
|
||||
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
|
||||
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
|
||||
- Tests: `tests/**`.
|
||||
- Migrations and database: `database/migrations/**`.
|
||||
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
|
||||
|
||||
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
|
||||
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
|
||||
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
|
||||
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
|
||||
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Detection Checklist
|
||||
|
||||
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
|
||||
|
||||
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
|
||||
|
||||
---
|
||||
|
||||
## A. Validation & HTTP input
|
||||
|
||||
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
|
||||
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
|
||||
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
|
||||
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
|
||||
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
|
||||
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
|
||||
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
|
||||
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
|
||||
|
||||
## B. Controllers & routing
|
||||
|
||||
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
|
||||
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
|
||||
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
|
||||
- Hint: read a few controller methods; `ls app/Actions app/Services`.
|
||||
7. Route handler style: closures in `routes/*.php` vs controller classes.
|
||||
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
|
||||
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
|
||||
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
|
||||
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
|
||||
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
|
||||
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
|
||||
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
|
||||
|
||||
## C. Authorization
|
||||
|
||||
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
|
||||
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
|
||||
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
|
||||
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
|
||||
|
||||
## D. Eloquent & models
|
||||
|
||||
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
|
||||
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
|
||||
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
|
||||
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
|
||||
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
|
||||
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
|
||||
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
|
||||
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
|
||||
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
|
||||
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
|
||||
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
|
||||
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
|
||||
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
|
||||
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
|
||||
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
|
||||
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
|
||||
|
||||
## E. Architecture & organization
|
||||
|
||||
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
|
||||
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
|
||||
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
|
||||
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
|
||||
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
|
||||
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
|
||||
24. Decoupling: events + listeners vs direct service calls.
|
||||
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
|
||||
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
|
||||
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
|
||||
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
|
||||
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
|
||||
27. Enums: backed vs pure; case naming; where they live.
|
||||
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
|
||||
|
||||
## F. Frontend & views
|
||||
|
||||
This app ships a frontend stack, so the items below apply.
|
||||
|
||||
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
|
||||
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
|
||||
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
|
||||
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
|
||||
30. Livewire component format: Volt functional/class components, native Livewire 4 single-file (SFC), multi-file (MFC), view-based, or class-based components. Evaluate full-page vs nested separately because it is an independent usage choice.
|
||||
- Hint: check the installed Livewire major and `livewire/volt`; inspect `app/Livewire`, `resources/views/livewire`, and Livewire 4 component/page directories for `@volt`, SFC, MFC, view-based, and class-based formats.
|
||||
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
|
||||
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
|
||||
|
||||
## G. Database & migrations
|
||||
|
||||
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
|
||||
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
|
||||
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
|
||||
- Hint: grep `function down` vs the migration count.
|
||||
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
|
||||
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
|
||||
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
|
||||
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
|
||||
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
|
||||
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
|
||||
|
||||
## H. Testing
|
||||
|
||||
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
|
||||
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
|
||||
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
|
||||
- Hint: grep those trait names in `tests/`.
|
||||
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
|
||||
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
|
||||
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
|
||||
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
|
||||
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
|
||||
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
|
||||
|
||||
## I. Responses & API resources
|
||||
|
||||
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
|
||||
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
|
||||
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
|
||||
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
|
||||
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
|
||||
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
|
||||
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
|
||||
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
|
||||
|
||||
## J. Strings, collections & dates
|
||||
|
||||
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
|
||||
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
|
||||
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
|
||||
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
|
||||
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
|
||||
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
|
||||
|
||||
---
|
||||
|
||||
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
|
||||
@@ -48,7 +48,7 @@ Cross-cutting changes often need more than one rule file.
|
||||
| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) |
|
||||
| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) |
|
||||
| Environment values and application configuration | [`rules/config.md`](rules/config.md) |
|
||||
| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) |
|
||||
| Tests: coverage, factories, fakes, and assertions | the `testing-best-practices` skill |
|
||||
| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
|
||||
| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Advanced Query Patterns
|
||||
# Advanced Query Best Practices
|
||||
|
||||
## Use `addSelect()` Subqueries for Single Values from Has-Many
|
||||
## Select Single Relationship Values with Subqueries
|
||||
|
||||
Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
|
||||
When only one value from a has-many relationship is needed, consider a correlated subquery with `addSelect()` instead of loading the entire relationship. This selects the value as part of the main query without an additional relationship query.
|
||||
|
||||
```php
|
||||
public function scopeWithLastLoginAt($query): void
|
||||
@@ -16,14 +16,14 @@ public function scopeWithLastLoginAt($query): void
|
||||
}
|
||||
```
|
||||
|
||||
## Create Dynamic Relationships via Subquery FK
|
||||
## Create Dynamic Relationships with a Subquery Foreign Key
|
||||
|
||||
Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
|
||||
The same pattern can select a foreign key and expose the selected model through a `belongsTo` relationship. Eager loading that relationship still executes a separate query, but it avoids loading the full has-many collection.
|
||||
|
||||
```php
|
||||
public function lastLogin(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Login::class);
|
||||
return $this->belongsTo(Login::class, 'last_login_id');
|
||||
}
|
||||
|
||||
public function scopeWithLastLogin($query): void
|
||||
@@ -37,9 +37,9 @@ public function scopeWithLastLogin($query): void
|
||||
}
|
||||
```
|
||||
|
||||
## Use Conditional Aggregates Instead of Multiple Count Queries
|
||||
## Combine Related Counts with Conditional Aggregates
|
||||
|
||||
Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
|
||||
Combine several counts over the same filtered data set into one query by using conditional aggregates. Use `toBase()` when only scalar values are needed and model hydration provides no benefit. Confirm the expression syntax against the application's database engine.
|
||||
|
||||
```php
|
||||
$statuses = Feature::toBase()
|
||||
@@ -49,50 +49,50 @@ $statuses = Feature::toBase()
|
||||
->first();
|
||||
```
|
||||
|
||||
## Use `setRelation()` to Prevent Circular N+1
|
||||
## Reuse Loaded Parent Models with `setRelation()`
|
||||
|
||||
When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
|
||||
When a parent and its children are already loaded and code also accesses `$child->parent`, set the inverse relationship to the existing parent instance. This avoids an additional lazy-loading query for each child.
|
||||
|
||||
```php
|
||||
$feature->load('comments.user');
|
||||
$feature->comments->each->setRelation('feature', $feature);
|
||||
```
|
||||
|
||||
## Prefer `whereIn` + Subquery Over `whereHas`
|
||||
## Compare `whereHas()` with an `IN` Subquery
|
||||
|
||||
`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
|
||||
`whereHas()` typically produces an `EXISTS` subquery, while `whereIn()` can express the same filter with an `IN` subquery. Either form may be faster depending on the database engine, indexes, cardinality, and query plan. Measure both forms with representative data; neither subquery loads its result set into PHP memory.
|
||||
|
||||
Incorrect (correlated EXISTS re-executes per row):
|
||||
Option using `EXISTS`:
|
||||
|
||||
```php
|
||||
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
|
||||
```
|
||||
|
||||
Correct (index-friendly subquery, no PHP memory overhead):
|
||||
Option using `IN`:
|
||||
|
||||
```php
|
||||
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
|
||||
```
|
||||
|
||||
## Sometimes Two Simple Queries Beat One Complex Query
|
||||
## Measure Two Simple Queries Against One Complex Query
|
||||
|
||||
Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
|
||||
Two targeted queries can outperform one complex correlated subquery or join when the first query is highly selective. They also add a database round trip, can transfer a large identifier list, and do not provide a single-query consistency snapshot. Decide from query plans and production-like measurements.
|
||||
|
||||
## Use Compound Indexes Matching `orderBy` Column Order
|
||||
## Design Composite Indexes for the Query
|
||||
|
||||
When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
|
||||
For common multi-column sorts, consider a composite index whose column order supports the query's filters and ordering. Database engines may combine indexes or choose an explicit sort, so matching the `ORDER BY` list alone does not guarantee that an index will be used. Verify the query plan.
|
||||
|
||||
```php
|
||||
// Migration
|
||||
$table->index(['last_name', 'first_name']);
|
||||
|
||||
// Query — column order must match the index
|
||||
// Query that this index may support
|
||||
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
|
||||
```
|
||||
|
||||
## Use Correlated Subqueries for Has-Many Ordering
|
||||
## Consider a Correlated Subquery for Has-Many Ordering
|
||||
|
||||
When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
|
||||
When sorting by one value from a has-many relationship, a direct join can duplicate parent rows unless it first reduces the related table to one row per parent. A correlated subquery in `orderBy()` is often simpler, but its performance depends on the query plan and supporting indexes.
|
||||
|
||||
```php
|
||||
public function scopeOrderByLastLogin($query): void
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Architecture Best Practices
|
||||
|
||||
## Single-Purpose Action Classes
|
||||
## Extract Focused Business Operations
|
||||
|
||||
Extract discrete business operations into invokable Action classes.
|
||||
Extract a discrete business operation into an action class when doing so makes the operation easier to reuse or test. An action class has no special meaning to Laravel; follow the project's naming and invocation conventions.
|
||||
|
||||
```php
|
||||
class CreateOrderAction
|
||||
@@ -19,11 +19,12 @@ class CreateOrderAction
|
||||
}
|
||||
```
|
||||
|
||||
## Use Dependency Injection
|
||||
## Inject Required Dependencies
|
||||
|
||||
Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
|
||||
Prefer constructor injection for dependencies required throughout an object's lifetime. Method injection is appropriate for dependencies needed by one controller action, listener, job handler, or other container-invoked method. Avoid `app()` and `resolve()` when normal injection can make a dependency explicit.
|
||||
|
||||
Hidden dependency:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class OrderController extends Controller
|
||||
{
|
||||
@@ -36,24 +37,24 @@ class OrderController extends Controller
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
Injected dependency:
|
||||
|
||||
```php
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(private OrderService $service) {}
|
||||
|
||||
public function store(StoreOrderRequest $request)
|
||||
public function store(StoreOrderRequest $request, OrderService $service)
|
||||
{
|
||||
return $this->service->create($request->validated());
|
||||
return $service->create($request->validated());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Code to Interfaces
|
||||
## Depend on Contracts at Boundaries
|
||||
|
||||
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
|
||||
Depend on contracts at system boundaries, such as payment gateways, notification channels, and external services, when testability or interchangeable implementations justify the abstraction.
|
||||
|
||||
Concrete boundary dependency:
|
||||
|
||||
Incorrect (concrete dependency):
|
||||
```php
|
||||
class OrderService
|
||||
{
|
||||
@@ -61,7 +62,8 @@ class OrderService
|
||||
}
|
||||
```
|
||||
|
||||
Correct (interface dependency):
|
||||
Contract boundary dependency:
|
||||
|
||||
```php
|
||||
interface PaymentGateway
|
||||
{
|
||||
@@ -80,86 +82,99 @@ Bind in a service provider:
|
||||
$this->app->bind(PaymentGateway::class, StripeGateway::class);
|
||||
```
|
||||
|
||||
## Default Sort by Descending
|
||||
## Specify a Deterministic Sort Order
|
||||
|
||||
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
|
||||
Without an explicit `ORDER BY`, row order is undefined. Choose an order that matches the feature, and add a unique tie-breaker when stable pagination matters.
|
||||
|
||||
Unspecified order:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::paginate();
|
||||
```
|
||||
|
||||
Correct:
|
||||
Newest first with a stable tie-breaker:
|
||||
|
||||
```php
|
||||
$posts = Post::latest()->paginate();
|
||||
$posts = Post::query()
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->paginate();
|
||||
```
|
||||
|
||||
## Use Atomic Locks for Race Conditions
|
||||
|
||||
Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
|
||||
Use a lock when concurrent execution must be serialized. `Cache::lock()` provides an atomic lock when the configured cache store supports locks. `lockForUpdate()` locks selected database rows and must run inside a database transaction. These mechanisms solve different coordination problems.
|
||||
|
||||
```php
|
||||
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
|
||||
$order->process();
|
||||
});
|
||||
|
||||
// Or at query level
|
||||
$product = Product::where('id', $id)->lockForUpdate()->first();
|
||||
// Or at query level, inside a transaction
|
||||
DB::transaction(function () use ($id) {
|
||||
$product = Product::where('id', $id)->lockForUpdate()->first();
|
||||
|
||||
// Read and update the product while the database lock is held.
|
||||
});
|
||||
```
|
||||
|
||||
## Use `mb_*` String Functions
|
||||
|
||||
When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
|
||||
When no Laravel helper exists, prefer multibyte-aware functions such as `mb_strlen()` and `mb_strtolower()` for UTF-8 text. For example, `strlen()` counts bytes, while `strtolower()` is not multibyte-aware.
|
||||
|
||||
Incorrect:
|
||||
|
||||
```php
|
||||
strlen('José'); // 5 (bytes, not characters)
|
||||
strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
|
||||
strlen('José'); // 5 bytes, not 4 characters
|
||||
strtolower('MÜNCHEN'); // Does not lowercase Ü
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```php
|
||||
mb_strlen('José'); // 4 (characters)
|
||||
mb_strtolower('MÜNCHEN'); // 'münchen'
|
||||
mb_strlen('José'); // 4 characters
|
||||
mb_strtolower('MÜNCHEN'); // 'münchen'
|
||||
|
||||
// Prefer Laravel's Str helpers when available
|
||||
Str::length('José'); // 4
|
||||
Str::lower('MÜNCHEN'); // 'münchen'
|
||||
Str::length('José'); // 4
|
||||
Str::lower('MÜNCHEN'); // 'münchen'
|
||||
```
|
||||
|
||||
## Use `defer()` for Post-Response Work
|
||||
|
||||
For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
|
||||
For lightweight work that does not need retries or crash durability, consider `defer()` instead of dispatching a job. During an HTTP request, the callback normally runs after the response has been sent but remains in the same PHP process.
|
||||
|
||||
Queued and durable:
|
||||
|
||||
Incorrect (job overhead for trivial work):
|
||||
```php
|
||||
dispatch(new LogPageView($page));
|
||||
```
|
||||
|
||||
Correct (runs after response, same process):
|
||||
Deferred in the current process:
|
||||
|
||||
```php
|
||||
defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
|
||||
```
|
||||
|
||||
Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
|
||||
Use a queued job when the work needs retries, queue controls, or durability across process failures.
|
||||
|
||||
## Use `Context` for Request-Scoped Data
|
||||
|
||||
The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
|
||||
The `Context` facade makes contextual data available across the current execution lifecycle without manually passing arguments through every layer.
|
||||
|
||||
```php
|
||||
// In middleware
|
||||
Context::add('tenant_id', $request->header('X-Tenant-ID'));
|
||||
|
||||
// Anywhere later — controllers, jobs, log context
|
||||
// Later in the same execution lifecycle
|
||||
$tenantId = Context::get('tenant_id');
|
||||
```
|
||||
|
||||
Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
|
||||
Visible context is added to log context, and both visible and hidden context are captured and restored for queued jobs. Use `Context::addHidden()` for data that should propagate to queued jobs without appearing in logs. Do not place secrets in context unless that propagation is intended.
|
||||
|
||||
## Use `Concurrency::run()` for Parallel Execution
|
||||
|
||||
Run independent operations in parallel using child processes — no async libraries needed.
|
||||
Run independent operations concurrently through Laravel's configured concurrency driver.
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Concurrency;
|
||||
@@ -170,13 +185,14 @@ use Illuminate\Support\Facades\Concurrency;
|
||||
]);
|
||||
```
|
||||
|
||||
Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
|
||||
With a process-based driver, each closure runs in a separate PHP process that boots the application. Use concurrency when independent database queries, HTTP client calls, or computations benefit enough to offset process and serialization overhead. The `sync` driver executes closures sequentially and is useful primarily during testing.
|
||||
|
||||
## Convention Over Configuration
|
||||
## Follow Framework Conventions
|
||||
|
||||
Follow Laravel conventions. Don't override defaults unnecessarily.
|
||||
Follow Laravel conventions unless the domain or an existing schema requires an override.
|
||||
|
||||
Customized schema:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class Customer extends Model
|
||||
{
|
||||
@@ -190,7 +206,8 @@ class Customer extends Model
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
Conventional schema:
|
||||
|
||||
```php
|
||||
class Customer extends Model
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Blade & Views Best Practices
|
||||
# Blade and View Best Practices
|
||||
|
||||
## Use `$attributes->merge()` in Component Templates
|
||||
|
||||
Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
|
||||
Use the component attribute bag so callers can add attributes. `merge()` combines default attributes with caller-provided values; class values receive special merging behavior.
|
||||
|
||||
```blade
|
||||
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
|
||||
@@ -12,25 +12,25 @@ Hardcoding classes prevents consumers from adding their own. `merge()` combines
|
||||
|
||||
## Use `@pushOnce` for Per-Component Scripts
|
||||
|
||||
If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
|
||||
If a component renders repeatedly, `@push` adds its script on every render. Use a consistently named `@pushOnce` block to add that content once per rendered response.
|
||||
|
||||
## Prefer Blade Components Over `@include`
|
||||
## Prefer Components for Explicit Interfaces
|
||||
|
||||
`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
|
||||
Use a Blade component when a reusable interface benefits from explicit props, an attribute bag, or slots. An include remains suitable for a small partial that intentionally uses the current view data; pass an explicit data array when implicit variable sharing would obscure its dependencies.
|
||||
|
||||
## Use View Composers for Shared View Data
|
||||
## Share Compatible View Data with a View Composer
|
||||
|
||||
If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
|
||||
Use a view composer to centralize data needed whenever one or more named Blade views are rendered. Keep the composer compatible with every view it targets, and avoid broad wildcards when views require different data shapes. A view composer runs when Laravel renders the matching view; it does not supply data to JSON, streamed, or other non-view responses.
|
||||
|
||||
## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
|
||||
## Return Blade Fragments for Partial Rendering
|
||||
|
||||
A single view can return either the full page or just a fragment, keeping routing clean.
|
||||
A route can return either a full view or a named fragment for clients such as htmx or Turbo.
|
||||
|
||||
```php
|
||||
return view('dashboard', compact('users'))
|
||||
->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
|
||||
```
|
||||
|
||||
## Use `@aware` for Deeply Nested Component Props
|
||||
## Share Parent Component Props with `@aware`
|
||||
|
||||
Avoids re-passing parent props through every level of nested components.
|
||||
Use `@aware` when a nested component needs a prop explicitly passed to an ancestor component. It does not expose an ancestor's default prop value unless that value was passed through the attribute bag.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# Caching Best Practices
|
||||
|
||||
## Use `Cache::remember()` Instead of Manual Get/Put
|
||||
## Use `Cache::remember()` for Cache-Aside Reads
|
||||
|
||||
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
|
||||
`Cache::remember()` implements a cache-aside read without a separate truthiness check. It does not prevent concurrent requests from computing the same missing value; use an atomic lock when duplicate computation must be prevented.
|
||||
|
||||
The manual version below incorrectly treats valid falsy values, such as `false` or `0`, as cache misses.
|
||||
|
||||
Incorrect:
|
||||
|
||||
```php
|
||||
$val = Cache::get('stats');
|
||||
if (! $val) {
|
||||
@@ -14,27 +17,42 @@ if (! $val) {
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```php
|
||||
$val = Cache::remember('stats', 60, fn () => $this->computeStats());
|
||||
```
|
||||
|
||||
## Use `Cache::flexible()` for Stale-While-Revalidate
|
||||
## Consider `Cache::flexible()` for Stale-While-Revalidate
|
||||
|
||||
On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
|
||||
For frequently read keys, `Cache::flexible()` can serve stale data during a defined stale period and register a deferred refresh. During an HTTP request, that refresh normally runs after the response; it is not a durable background job. Once the stale period has elapsed, the request recomputes the value synchronously.
|
||||
|
||||
Incorrect: `Cache::remember('users', 300, fn () => User::all());`
|
||||
Synchronous expiration:
|
||||
|
||||
Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
|
||||
```php
|
||||
Cache::remember('users', 300, fn () => User::all());
|
||||
```
|
||||
|
||||
## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
|
||||
Stale-while-revalidate tradeoff:
|
||||
|
||||
If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
|
||||
```php
|
||||
Cache::flexible('users', [300, 600], fn () => User::all());
|
||||
```
|
||||
|
||||
`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
|
||||
This value is fresh for five minutes and may be served stale until ten minutes after it was cached.
|
||||
|
||||
## Use `Cache::memo()` to Avoid Redundant Hits Within an Execution
|
||||
|
||||
If the same cache key is read repeatedly during one request or job, `memo()` decorates a cache store and retains resolved values in memory for that execution.
|
||||
|
||||
```php
|
||||
$settings = Cache::memo()->get('settings');
|
||||
```
|
||||
|
||||
Repeated reads through the same memoized store avoid additional store lookups. Writes through the memoized store update or invalidate its in-memory values as appropriate.
|
||||
|
||||
## Use Cache Tags to Invalidate Related Groups
|
||||
|
||||
Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
|
||||
Tags group related entries for invalidation without tracking each key. Cache tags are not supported by the `file`, `dynamodb`, or `database` drivers; confirm support before choosing a store.
|
||||
|
||||
```php
|
||||
Cache::tags(['user-1'])->flush();
|
||||
@@ -42,15 +60,27 @@ Cache::tags(['user-1'])->flush();
|
||||
|
||||
## Use `Cache::add()` for Atomic Conditional Writes
|
||||
|
||||
`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
|
||||
`add()` atomically writes a value only when the key does not already exist.
|
||||
|
||||
Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
|
||||
Incorrect:
|
||||
|
||||
Correct: `Cache::add('lock', true, 10);`
|
||||
```php
|
||||
if (! Cache::has('lock')) {
|
||||
Cache::put('lock', true, 10);
|
||||
}
|
||||
```
|
||||
|
||||
## Use `once()` for Per-Request Memoization
|
||||
Correct:
|
||||
|
||||
`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
|
||||
```php
|
||||
Cache::add('lock', true, 10);
|
||||
```
|
||||
|
||||
Use `Cache::lock()` rather than an ordinary cache key when lock ownership and safe release are required.
|
||||
|
||||
## Use `once()` for In-Process Memoization
|
||||
|
||||
`once()` memoizes a callback's return value for the current request or job. Calls made from an object instance are scoped to that instance. Unlike `Cache::memo()`, `once()` does not read from an external cache store.
|
||||
|
||||
```php
|
||||
public function roles(): Collection
|
||||
@@ -59,11 +89,11 @@ public function roles(): Collection
|
||||
}
|
||||
```
|
||||
|
||||
Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
|
||||
Repeated calls return the memoized result without rerunning the callback. Use `once()` for repeated computation within one execution. Use `Cache::memo()` to memoize access to an underlying store that can also persist values across executions.
|
||||
|
||||
## Configure Failover Cache Stores in Production
|
||||
|
||||
If Redis goes down, the app falls back to a secondary store automatically.
|
||||
The failover driver tries each configured store in order when a store operation throws an exception. It does not consult later stores for an ordinary cache miss, and data is not replicated between stores.
|
||||
|
||||
```php
|
||||
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
||||
|
||||
@@ -2,41 +2,69 @@
|
||||
|
||||
## Use Higher-Order Messages for Simple Operations
|
||||
|
||||
Incorrect:
|
||||
Explicit closure:
|
||||
|
||||
```php
|
||||
$users->each(function (User $user) {
|
||||
$user->markAsVip();
|
||||
});
|
||||
```
|
||||
|
||||
Correct: `$users->each->markAsVip();`
|
||||
Concise equivalent:
|
||||
|
||||
Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
|
||||
```php
|
||||
$users->each->markAsVip();
|
||||
```
|
||||
|
||||
## Choose `cursor()` vs. `lazy()` Correctly
|
||||
Higher-order messages are available for supported collection methods such as `each`, `map`, `filter`, and `sum`. Use an explicit closure when arguments or nontrivial logic would be clearer.
|
||||
|
||||
- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
|
||||
- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
|
||||
## Choose Between `cursor()` and `lazy()`
|
||||
|
||||
Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
|
||||
`cursor()` executes one query and hydrates models individually, but it cannot eager load relationships. The database driver's result buffering can still consume substantial memory for very large results. Use it for low-memory, attribute-only iteration when one long-running query is acceptable.
|
||||
|
||||
Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
|
||||
`lazy()` executes multiple chunked queries and returns a flat `LazyCollection`. It supports eager loading relationships for each chunk and avoids holding one database cursor open for the entire iteration.
|
||||
|
||||
With relationships:
|
||||
|
||||
```php
|
||||
User::with('roles')->lazy()->each(function (User $user) {
|
||||
// The roles for this chunk have been eager loaded.
|
||||
});
|
||||
```
|
||||
|
||||
Without relationships:
|
||||
|
||||
```php
|
||||
User::cursor()->each(function (User $user) {
|
||||
// Process model attributes.
|
||||
});
|
||||
```
|
||||
|
||||
## Use `lazyById()` When Updating Records While Iterating
|
||||
|
||||
`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
|
||||
`lazy()` uses offset pagination, so updates to columns that affect the query can shift rows and cause records to be skipped or processed twice. `lazyById()` paginates by a monotonic key and is safer when updating other columns during iteration. Do not change the pagination key itself while iterating.
|
||||
|
||||
## Use `toQuery()` for Bulk Operations on Collections
|
||||
|
||||
Avoids manual `whereIn` construction.
|
||||
Use `toQuery()` to build a query from the models in an Eloquent collection instead of manually constructing a `whereIn` clause.
|
||||
|
||||
Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
|
||||
Manual query:
|
||||
|
||||
Correct: `$users->toQuery()->update([...]);`
|
||||
```php
|
||||
User::whereIn('id', $users->modelKeys())->update(['active' => false]);
|
||||
```
|
||||
|
||||
Collection query:
|
||||
|
||||
```php
|
||||
$users->toQuery()->update(['active' => false]);
|
||||
```
|
||||
|
||||
`toQuery()` requires a non-empty Eloquent collection whose models are of the same type. Like other bulk Eloquent updates, it does not dispatch per-model update events, so use it only when those events are not required.
|
||||
|
||||
## Use `#[CollectedBy]` for Custom Collection Classes
|
||||
|
||||
More declarative than overriding `newCollection()`.
|
||||
The `#[CollectedBy]` attribute declares the custom collection class without requiring a `newCollection()` override.
|
||||
|
||||
```php
|
||||
#[CollectedBy(UserCollection::class)]
|
||||
|
||||
@@ -1,73 +1,85 @@
|
||||
# Configuration Best Practices
|
||||
|
||||
## `env()` Only in Config Files
|
||||
## Read Environment Variables in Configuration Files
|
||||
|
||||
Direct `env()` calls may return `null` when config is cached.
|
||||
Call `env()` only from configuration files. After configuration is cached, Laravel does not load the application's `.env` file, so application code should read configuration values through `config()`.
|
||||
|
||||
Incorrect:
|
||||
|
||||
```php
|
||||
$key = env('API_KEY');
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```php
|
||||
// config/services.php
|
||||
'key' => env('API_KEY'),
|
||||
return [
|
||||
'key' => env('API_KEY'),
|
||||
];
|
||||
|
||||
// Application code
|
||||
$key = config('services.key');
|
||||
```
|
||||
|
||||
## Use Encrypted Env or External Secrets
|
||||
## Protect Production Secrets
|
||||
|
||||
Never store production secrets in plain `.env` files in version control.
|
||||
Do not commit plaintext production secrets. Laravel can encrypt an environment file so its encrypted form can be stored safely, while deployment platforms can supply secrets through their native secret stores.
|
||||
|
||||
Incorrect:
|
||||
|
||||
```bash
|
||||
|
||||
# .env committed to repo or shared in Slack
|
||||
|
||||
# A plaintext .env file committed to the repository
|
||||
STRIPE_SECRET=sk_live_abc123
|
||||
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
|
||||
```
|
||||
|
||||
Correct:
|
||||
Encrypted environment file:
|
||||
|
||||
```bash
|
||||
php artisan env:encrypt --env=production --readable
|
||||
php artisan env:decrypt --env=production
|
||||
```
|
||||
|
||||
For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
|
||||
For hosted deployments, consider the platform's native secret store, such as AWS Secrets Manager or Vault, and inject secrets at runtime.
|
||||
|
||||
## Use `App::environment()` for Environment Checks
|
||||
|
||||
Incorrect:
|
||||
|
||||
```php
|
||||
if (env('APP_ENV') === 'production') {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```php
|
||||
if (app()->isProduction()) {
|
||||
// or
|
||||
// ...
|
||||
}
|
||||
|
||||
if (App::environment('production')) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Use Constants and Language Files
|
||||
## Name Repeated Domain Values
|
||||
|
||||
Use class constants instead of hardcoded magic strings for model states, types, and statuses.
|
||||
Use an enum or class constant when a domain value is repeated or represents a constrained set. A one-off string literal does not always need a named constant.
|
||||
|
||||
```php
|
||||
// Incorrect
|
||||
// Repeated literal
|
||||
return $this->type === 'normal';
|
||||
|
||||
// Correct
|
||||
// Named domain value
|
||||
return $this->type === self::TYPE_NORMAL;
|
||||
```
|
||||
|
||||
If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
|
||||
If the application supports localization, put user-facing strings in language files and retrieve them with `__()`. Simple literals are reasonable for applications that intentionally do not support multiple languages.
|
||||
|
||||
```php
|
||||
// Only when lang files already exist in the project
|
||||
// In a localized application
|
||||
return back()->with('message', __('app.article_added'));
|
||||
```
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Database Performance Best Practices
|
||||
|
||||
## Always Eager Load Relationships
|
||||
## Eager Load Relationships Before Iterating
|
||||
|
||||
Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
|
||||
When a relationship will be accessed for many models, eager load it with `with()` to avoid running one initial query plus one relationship query per model, commonly called an N+1 query pattern. Lazy loading is reasonable when the relationship may not be needed or only one model is involved.
|
||||
|
||||
Lazy-loaded version:
|
||||
|
||||
Incorrect (N+1 — executes 1 + N queries):
|
||||
```php
|
||||
$posts = Post::all();
|
||||
foreach ($posts as $post) {
|
||||
@@ -12,7 +13,8 @@ foreach ($posts as $post) {
|
||||
}
|
||||
```
|
||||
|
||||
Correct (2 queries total):
|
||||
Eager-loaded version:
|
||||
|
||||
```php
|
||||
$posts = Post::with('author')->get();
|
||||
foreach ($posts as $post) {
|
||||
@@ -20,7 +22,7 @@ foreach ($posts as $post) {
|
||||
}
|
||||
```
|
||||
|
||||
Constrain eager loads to select only needed columns (always include the foreign key):
|
||||
Constrain eager loads when large columns are unnecessary. Include the related model's primary key and every column Eloquent needs to match the relationship. In this example, `users.id` and `posts.user_id` match posts to users, while selecting `posts.id` preserves each related model's primary key:
|
||||
|
||||
```php
|
||||
$users = User::with(['posts' => function ($query) {
|
||||
@@ -42,31 +44,34 @@ public function boot(): void
|
||||
}
|
||||
```
|
||||
|
||||
Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
|
||||
By default, accessing an unloaded relationship then throws a `LazyLoadingViolationException`. Applications can customize violation handling with `handleLazyLoadingViolationUsing()`.
|
||||
|
||||
## Select Only Needed Columns
|
||||
|
||||
Avoid `SELECT *` — especially when tables have large text or JSON columns.
|
||||
Select only the columns the operation needs when omitting large text, binary, or JSON columns provides a meaningful benefit.
|
||||
|
||||
All columns:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::with('author')->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
Selected columns:
|
||||
|
||||
```php
|
||||
$posts = Post::select('id', 'title', 'user_id', 'created_at')
|
||||
->with(['author:id,name,avatar'])
|
||||
->get();
|
||||
```
|
||||
|
||||
When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
|
||||
When limiting selected columns, retain every key Eloquent needs for matching. A `belongsTo` relationship needs its foreign key on the parent query and the owner's key on the related query. A `hasMany` relationship needs the parent's local key and the related model's foreign key.
|
||||
|
||||
## Chunk Large Datasets
|
||||
## Process Large Data Sets Incrementally
|
||||
|
||||
Never load thousands of records at once. Use chunking for batch processing.
|
||||
Use chunking or lazy iteration when loading an entire result set would exceed the application's practical memory budget.
|
||||
|
||||
Loads the complete result set:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users = User::all();
|
||||
foreach ($users as $user) {
|
||||
@@ -74,7 +79,8 @@ foreach ($users as $user) {
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
Processes bounded chunks:
|
||||
|
||||
```php
|
||||
User::where('subscribed', true)->chunk(200, function ($users) {
|
||||
foreach ($users as $user) {
|
||||
@@ -83,7 +89,7 @@ User::where('subscribed', true)->chunk(200, function ($users) {
|
||||
});
|
||||
```
|
||||
|
||||
Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
|
||||
Use `chunkById()` when updates can change which rows match the query. Standard `chunk()` uses offset pagination, whose result positions can shift as rows change:
|
||||
|
||||
```php
|
||||
User::where('active', false)->chunkById(200, function ($users) {
|
||||
@@ -91,11 +97,14 @@ User::where('active', false)->chunkById(200, function ($users) {
|
||||
});
|
||||
```
|
||||
|
||||
## Add Database Indexes
|
||||
For read-only, attribute-only iteration, `cursor()` hydrates models individually from one query, although some database drivers still buffer raw results. Use `lazy()` when relationships must be eager loaded in chunks, and use `lazyById()` or `chunkById()` when updates can affect query membership. See the collection rules for detailed tradeoffs.
|
||||
|
||||
Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
|
||||
## Add Indexes for Measured Query Patterns
|
||||
|
||||
Design indexes around frequent, performance-sensitive query patterns. A column's presence in `WHERE`, `ORDER BY`, `JOIN`, or `GROUP BY` does not by itself justify an index; selectivity, write cost, existing indexes, and the database query plan all matter.
|
||||
|
||||
Schema without an application-specific query index:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
@@ -105,24 +114,26 @@ Schema::create('orders', function (Blueprint $table) {
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
Schema optimized for `WHERE status = ? ORDER BY created_at`:
|
||||
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->index()->constrained();
|
||||
$table->string('status')->index();
|
||||
$table->foreignId('user_id')->constrained();
|
||||
$table->string('status');
|
||||
$table->timestamps();
|
||||
$table->index(['status', 'created_at']);
|
||||
});
|
||||
```
|
||||
|
||||
Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
|
||||
Confirm composite index column order and effectiveness with production-like data and the database's query-plan tools. Also check whether the database already created an index to support a foreign key before adding another one.
|
||||
|
||||
## Use `withCount()` for Counting Relations
|
||||
## Count Relationships Without Loading Them
|
||||
|
||||
Never load entire collections just to count them.
|
||||
Use `withCount()` when only relationship counts are needed; loading and hydrating every related model wastes memory.
|
||||
|
||||
Loads related models:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::all();
|
||||
foreach ($posts as $post) {
|
||||
@@ -130,7 +141,8 @@ foreach ($posts as $post) {
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
Selects relationship counts:
|
||||
|
||||
```php
|
||||
$posts = Post::withCount('comments')->get();
|
||||
foreach ($posts as $post) {
|
||||
@@ -149,39 +161,24 @@ $posts = Post::withCount([
|
||||
])->get();
|
||||
```
|
||||
|
||||
## Use `cursor()` for Memory-Efficient Iteration
|
||||
## Keep Queries Out of Blade Templates
|
||||
|
||||
For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
|
||||
Prepare data before rendering a Blade template, such as in a controller, query service, or view composer. This keeps query behavior visible and testable.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users = User::where('active', true)->get();
|
||||
```
|
||||
Query in the template:
|
||||
|
||||
Correct:
|
||||
```php
|
||||
foreach (User::where('active', true)->cursor() as $user) {
|
||||
ProcessUser::dispatch($user->id);
|
||||
}
|
||||
```
|
||||
|
||||
Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
|
||||
|
||||
## No Queries in Blade Templates
|
||||
|
||||
Never execute queries in Blade templates. Pass data from controllers.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
@foreach (User::all() as $user)
|
||||
{{ $user->profile->name }}
|
||||
@endforeach
|
||||
```
|
||||
|
||||
Correct:
|
||||
Data prepared before rendering:
|
||||
|
||||
```php
|
||||
// Controller
|
||||
$users = User::with('profile')->get();
|
||||
|
||||
return view('users.index', compact('users'));
|
||||
```
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Eloquent Best Practices
|
||||
|
||||
## Use Correct Relationship Types
|
||||
## Define Precise Relationship Types
|
||||
|
||||
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
|
||||
Define the relationship that matches the database association, and declare its concrete return type.
|
||||
|
||||
```php
|
||||
public function comments(): HasMany
|
||||
@@ -20,7 +20,8 @@ public function author(): BelongsTo
|
||||
|
||||
Extract reusable query constraints into local scopes to avoid duplication.
|
||||
|
||||
Incorrect:
|
||||
Duplicated constraints:
|
||||
|
||||
```php
|
||||
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
|
||||
$articles = Article::whereHas('user', function ($q) {
|
||||
@@ -28,9 +29,11 @@ $articles = Article::whereHas('user', function ($q) {
|
||||
})->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
Reusable local scope:
|
||||
|
||||
```php
|
||||
public function scopeActive(Builder $query): Builder
|
||||
#[Scope]
|
||||
protected function active(Builder $query): Builder
|
||||
{
|
||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||
}
|
||||
@@ -44,7 +47,8 @@ $articles = Article::whereHas('user', fn ($q) => $q->active())->get();
|
||||
|
||||
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
|
||||
|
||||
Incorrect (global scope for a conditional filter):
|
||||
Global scope tradeoff:
|
||||
|
||||
```php
|
||||
class PublishedScope implements Scope
|
||||
{
|
||||
@@ -53,12 +57,15 @@ class PublishedScope implements Scope
|
||||
$builder->where('published', true);
|
||||
}
|
||||
}
|
||||
// Now admin panels, reports, and background jobs all silently skip drafts
|
||||
|
||||
// Admin panels, reports, and jobs now omit drafts unless the scope is removed.
|
||||
```
|
||||
|
||||
Correct (local scope you opt into):
|
||||
Explicit local scope:
|
||||
|
||||
```php
|
||||
public function scopePublished(Builder $query): Builder
|
||||
#[Scope]
|
||||
protected function published(Builder $query): Builder
|
||||
{
|
||||
return $query->where('published', true);
|
||||
}
|
||||
@@ -82,16 +89,18 @@ protected function casts(): array
|
||||
}
|
||||
```
|
||||
|
||||
## Cast Date Columns Properly
|
||||
## Cast Date and Time Attributes
|
||||
|
||||
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
|
||||
Cast a date or timestamp attribute when application code should treat it as a Carbon instance. Eloquent already casts the conventional `created_at` and `updated_at` timestamps.
|
||||
|
||||
Manual parsing in the template:
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
|
||||
{{ Carbon::parse($order->ordered_at)->toDateString() }}
|
||||
```
|
||||
|
||||
Correct:
|
||||
Model cast:
|
||||
|
||||
```php
|
||||
protected function casts(): array
|
||||
{
|
||||
@@ -108,24 +117,27 @@ protected function casts(): array
|
||||
|
||||
## Use `whereBelongsTo()` for Relationship Queries
|
||||
|
||||
Cleaner than manually specifying foreign keys.
|
||||
`whereBelongsTo()` expresses the relationship constraint without manually specifying its foreign key.
|
||||
|
||||
Foreign key constraint:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Post::where('user_id', $user->id)->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
Relationship-aware constraint:
|
||||
|
||||
```php
|
||||
Post::whereBelongsTo($user)->get();
|
||||
Post::whereBelongsTo($user, 'author')->get();
|
||||
```
|
||||
|
||||
## Avoid Hardcoded Table Names in Queries
|
||||
## Keep Application Queries Model-Aware
|
||||
|
||||
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
|
||||
Prefer Eloquent models and relationships for model-backed application queries. They preserve casts, scopes, and model table configuration. The query builder and raw SQL legitimately require table names, so use them when their lower-level behavior is intentional.
|
||||
|
||||
Lower-level alternatives:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
DB::table('users')->where('active', true)->get();
|
||||
|
||||
@@ -134,15 +146,13 @@ $query->join('companies', 'companies.id', '=', 'users.company_id');
|
||||
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
|
||||
```
|
||||
|
||||
Correct — reference the model's table:
|
||||
```php
|
||||
DB::table((new User)->getTable())->where('active', true)->get();
|
||||
Model-aware queries:
|
||||
|
||||
// Even better — use Eloquent or the query builder instead of raw SQL
|
||||
```php
|
||||
User::where('active', true)->get();
|
||||
Order::where('status', 'pending')->get();
|
||||
```
|
||||
|
||||
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
||||
When a query builder operation should follow a model's configured table name, use `(new User)->getTable()`. For complex joins or raw SQL, explicit table names may be clearer; keep those references covered by tests when schema changes are possible.
|
||||
|
||||
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
||||
In migrations, use explicit table names rather than application models. Migrations are historical snapshots, while models and their scopes can change after a migration is deployed.
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
# Error Handling Best Practices
|
||||
|
||||
## Exception Reporting and Rendering
|
||||
## Choose Where to Report and Render Exceptions
|
||||
|
||||
There are two valid approaches — choose one and apply it consistently across the project.
|
||||
Laravel supports exception-specific methods and centralized handler callbacks. Follow the pattern already established by the project.
|
||||
|
||||
**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
|
||||
Exception methods keep behavior beside the exception definition:
|
||||
|
||||
```php
|
||||
class InvalidOrderException extends Exception
|
||||
{
|
||||
public function report(): void { /* custom reporting */ }
|
||||
public function report(): void
|
||||
{
|
||||
// Send the exception to a custom reporter.
|
||||
}
|
||||
|
||||
public function render(Request $request): Response
|
||||
{
|
||||
@@ -18,38 +21,40 @@ class InvalidOrderException extends Exception
|
||||
}
|
||||
```
|
||||
|
||||
**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
|
||||
Centralized callbacks in `bootstrap/app.php` keep the application's exception policy together:
|
||||
|
||||
```php
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
$exceptions->report(function (InvalidOrderException $e) { /* ... */ });
|
||||
$exceptions->report(function (InvalidOrderException $e) {
|
||||
// Send the exception to a custom reporter.
|
||||
});
|
||||
$exceptions->render(function (InvalidOrderException $e, Request $request) {
|
||||
return response()->view('errors.invalid-order', status: 422);
|
||||
});
|
||||
})
|
||||
```
|
||||
|
||||
Check the existing codebase and follow whichever pattern is already established.
|
||||
An exception's `report()` method suppresses Laravel's default reporting unless it returns `false`. A report callback allows default reporting unless it returns `false` or is chained with `stop()`. Use `ShouldntReport` or `dontReport()` when the handler should not report an exception at all. By contrast, returning `false` from a `render()` method or render callback defers to Laravel's default rendering.
|
||||
|
||||
## Use `ShouldntReport` for Exceptions That Should Never Log
|
||||
## Mark Exceptions the Handler Should Not Report
|
||||
|
||||
More discoverable than listing classes in `dontReport()`.
|
||||
Implementing `ShouldntReport` prevents Laravel's exception handler from reporting that exception type and keeps the policy visible on the class. It does not prevent application code from logging the exception explicitly.
|
||||
|
||||
```php
|
||||
class PodcastProcessingException extends Exception implements ShouldntReport {}
|
||||
```
|
||||
|
||||
## Throttle High-Volume Exceptions
|
||||
## Throttle High-Volume Exception Reports
|
||||
|
||||
A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
|
||||
A failing integration can flood logs or error tracking. Configure `throttle()` with a `Lottery` or `Limit` result to sample or rate-limit matching exception reports. Choose keys deliberately when separate exception classes, tenants, or integrations need independent limits.
|
||||
|
||||
## Enable `dontReportDuplicates()`
|
||||
## Prevent Duplicate Reports of One Exception Instance
|
||||
|
||||
Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
|
||||
Enable `dontReportDuplicates()` when the same exception object may pass through multiple `report($exception)` calls. It deduplicates by object identity, not by exception class or message.
|
||||
|
||||
## Force JSON Error Rendering for API Routes
|
||||
## Define JSON Rendering for API Routes
|
||||
|
||||
Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
|
||||
Laravel normally uses request content negotiation to decide whether to render an exception as JSON. If the application's API contract requires JSON regardless of the `Accept` header, define that policy explicitly for the relevant routes.
|
||||
|
||||
```php
|
||||
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||
@@ -59,7 +64,7 @@ $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||
|
||||
## Add Context to Exception Classes
|
||||
|
||||
Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
|
||||
Attach structured data to an exception through `context()`. Laravel merges that data into the exception's log context when the handler reports it.
|
||||
|
||||
```php
|
||||
class InvalidOrderException extends Exception
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
# Events & Notifications Best Practices
|
||||
# Events and Notifications Best Practices
|
||||
|
||||
## Rely on Event Discovery
|
||||
|
||||
Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
|
||||
Laravel discovers listeners in the configured listener directories by inspecting type-hinted event arguments on `handle()` or `__invoke()` methods. Register listeners manually only when discovery is disabled, the listener is outside those directories, or explicit registration is clearer.
|
||||
|
||||
## Run `event:cache` in Production Deploy
|
||||
## Cache Event Discovery During Production Deployment
|
||||
|
||||
Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
|
||||
Cache discovered listeners during production deployment with `php artisan optimize` or `php artisan event:cache`. Rebuild the cache whenever listener definitions change.
|
||||
|
||||
## Use `ShouldDispatchAfterCommit` Inside Transactions
|
||||
|
||||
Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
|
||||
When an event is dispatched inside a database transaction, `ShouldDispatchAfterCommit` delays dispatch until all open database transactions commit. If a transaction rolls back, Laravel discards the event. This affects synchronous and queued listeners; it is not limited to queue timing.
|
||||
|
||||
```php
|
||||
class OrderShipped implements ShouldDispatchAfterCommit {}
|
||||
```
|
||||
|
||||
## Always Queue Notifications
|
||||
## Queue Slow Notifications
|
||||
|
||||
Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
|
||||
Queue notifications that call external services, such as email, text messaging, or Slack, when they do not need to complete before the response. Keep a notification synchronous when immediate completion or failure feedback is part of the operation.
|
||||
|
||||
```php
|
||||
class InvoicePaid extends Notification implements ShouldQueue
|
||||
@@ -27,9 +27,9 @@ class InvoicePaid extends Notification implements ShouldQueue
|
||||
}
|
||||
```
|
||||
|
||||
## Use `afterCommit()` on Notifications in Transactions
|
||||
## Dispatch Queued Notifications After Commit
|
||||
|
||||
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
|
||||
A queued notification sent inside a database transaction can run before the transaction commits. Call `afterCommit()` on the queued notification, or enable the queue connection's `after_commit` option, when its delivery depends on committed data. This setting has no scheduling effect on a synchronous notification.
|
||||
|
||||
```php
|
||||
$user->notify((new InvoicePaid($invoice))->afterCommit());
|
||||
@@ -37,7 +37,7 @@ $user->notify((new InvoicePaid($invoice))->afterCommit());
|
||||
|
||||
## Route Notification Channels to Dedicated Queues
|
||||
|
||||
Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
|
||||
Different notification channels can have different latency and priority requirements. Implement `viaQueues()` when channels should use separate queues.
|
||||
|
||||
## Use On-Demand Notifications for Non-User Recipients
|
||||
|
||||
@@ -49,4 +49,4 @@ Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
|
||||
|
||||
## Implement `HasLocalePreference` on Notifiable Models
|
||||
|
||||
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
||||
Implement `HasLocalePreference::preferredLocale()` on a notifiable model when notifications and mailables should use the recipient's locale. Laravel also preserves that locale for queued delivery. An explicit `locale()` call can still override the preference for an individual notification.
|
||||
|
||||
@@ -1,86 +1,100 @@
|
||||
# HTTP Client Best Practices
|
||||
|
||||
## Always Set Explicit Timeouts
|
||||
## Set Explicit Timeouts
|
||||
|
||||
The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
|
||||
Laravel's HTTP client has a 30-second response timeout by default. Choose response and connection timeouts that fit the service and the calling request or job. Remember that retries can multiply the total elapsed time.
|
||||
|
||||
Less resilient:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users');
|
||||
```
|
||||
|
||||
Correct:
|
||||
Preferred:
|
||||
|
||||
```php
|
||||
$response = Http::timeout(5)
|
||||
->connectTimeout(3)
|
||||
$response = Http::connectTimeout(3)
|
||||
->timeout(5)
|
||||
->get('https://api.example.com/users');
|
||||
```
|
||||
|
||||
For service-specific clients, define timeouts in a macro:
|
||||
Define shared settings in a macro or a dedicated client:
|
||||
|
||||
```php
|
||||
Http::macro('github', function () {
|
||||
return Http::baseUrl('https://api.github.com')
|
||||
->timeout(10)
|
||||
->connectTimeout(3)
|
||||
->timeout(10)
|
||||
->withToken(config('services.github.token'));
|
||||
});
|
||||
|
||||
$response = Http::github()->get('/repos/laravel/framework');
|
||||
```
|
||||
|
||||
## Use Retry with Backoff for External APIs
|
||||
## Retry Only Safe Operations
|
||||
|
||||
External APIs have transient failures. Use `retry()` with increasing delays.
|
||||
Retry transient connection failures, rate-limit responses, and server errors with an appropriate delay. Retry idempotent requests such as `GET` when the operation can safely run more than once. Retry a state-changing request only when the remote API supports an idempotency key or provides equivalent duplicate protection.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::post('https://api.stripe.com/v1/charges', $data);
|
||||
Unsafe without an idempotency guarantee:
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new PaymentFailedException('Charge failed');
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$response = Http::retry([100, 500, 1000])
|
||||
->timeout(10)
|
||||
->post('https://api.stripe.com/v1/charges', $data);
|
||||
->post('https://api.example.com/v1/charges', $data);
|
||||
```
|
||||
|
||||
Only retry on specific errors:
|
||||
Safe for an idempotent request:
|
||||
|
||||
```php
|
||||
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
|
||||
return $exception instanceof ConnectionException
|
||||
|| ($exception instanceof RequestException && $exception->response->serverError());
|
||||
})->post('https://api.example.com/data');
|
||||
$response = Http::connectTimeout(3)
|
||||
->timeout(10)
|
||||
->retry([100, 500, 1000], 0, function (Throwable $exception) {
|
||||
return $exception instanceof ConnectionException
|
||||
|| ($exception instanceof RequestException
|
||||
&& ($exception->response->serverError() || $exception->response->status() === 429));
|
||||
})
|
||||
->get('https://api.example.com/data');
|
||||
```
|
||||
|
||||
For a supported state-changing API, send a stable idempotency key for every attempt:
|
||||
|
||||
```php
|
||||
$response = Http::withHeaders(['Idempotency-Key' => $paymentAttempt->uuid])
|
||||
->connectTimeout(3)
|
||||
->timeout(10)
|
||||
->retry([100, 500, 1000], 0, function (Throwable $exception) {
|
||||
return $exception instanceof ConnectionException
|
||||
|| ($exception instanceof RequestException
|
||||
&& ($exception->response->serverError() || $exception->response->status() === 429));
|
||||
})
|
||||
->post('https://api.example.com/v1/charges', $data);
|
||||
```
|
||||
|
||||
## Handle Errors Explicitly
|
||||
|
||||
The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
|
||||
The HTTP client returns responses for `4xx` and `5xx` status codes instead of throwing by default. Inspect the expected statuses or call `throw()` before consuming a success payload.
|
||||
|
||||
Unsafe when a success payload is expected:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users/1');
|
||||
$user = $response->json(); // Could be an error body
|
||||
$user = Http::get('https://api.example.com/users/1')->json();
|
||||
```
|
||||
|
||||
Correct:
|
||||
Preferred:
|
||||
|
||||
```php
|
||||
$response = Http::timeout(5)
|
||||
$user = Http::connectTimeout(3)
|
||||
->timeout(5)
|
||||
->get('https://api.example.com/users/1')
|
||||
->throw();
|
||||
|
||||
$user = $response->json();
|
||||
->throw()
|
||||
->json();
|
||||
```
|
||||
|
||||
For graceful degradation:
|
||||
Handle expected alternatives explicitly when graceful degradation is required:
|
||||
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users/1');
|
||||
$response = Http::connectTimeout(3)
|
||||
->timeout(5)
|
||||
->get('https://api.example.com/users/1');
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
@@ -93,46 +107,30 @@ if ($response->notFound()) {
|
||||
$response->throw();
|
||||
```
|
||||
|
||||
## Use Request Pooling for Concurrent Requests
|
||||
## Pool Independent Requests
|
||||
|
||||
When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
|
||||
Use `Http::pool()` when several independent requests can run concurrently. Pooling changes execution time, not error handling; inspect or throw for each response as needed.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users = Http::get('https://api.example.com/users')->json();
|
||||
$posts = Http::get('https://api.example.com/posts')->json();
|
||||
$comments = Http::get('https://api.example.com/comments')->json();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
use Illuminate\Http\Client\Pool;
|
||||
|
||||
$responses = Http::pool(fn (Pool $pool) => [
|
||||
$pool->as('users')->get('https://api.example.com/users'),
|
||||
$pool->as('posts')->get('https://api.example.com/posts'),
|
||||
$pool->as('comments')->get('https://api.example.com/comments'),
|
||||
$pool->as('users')->connectTimeout(3)->timeout(5)
|
||||
->get('https://api.example.com/users'),
|
||||
$pool->as('posts')->connectTimeout(3)->timeout(5)
|
||||
->get('https://api.example.com/posts'),
|
||||
]);
|
||||
|
||||
$users = $responses['users']->json();
|
||||
$posts = $responses['posts']->json();
|
||||
$users = $responses['users']->throw()->json();
|
||||
$posts = $responses['posts']->throw()->json();
|
||||
```
|
||||
|
||||
## Fake HTTP Calls in Tests
|
||||
## Fake HTTP Requests in Tests
|
||||
|
||||
Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
|
||||
Use `Http::fake()` for external integrations, and use `Http::preventStrayRequests()` when an unexpected real request should fail the test. Also test timeouts, connection failures, and error responses that the application handles.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
it('syncs user from API', function () {
|
||||
$service = new UserSyncService;
|
||||
$service->sync(1); // Hits the real API
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
it('syncs user from API', function () {
|
||||
it('syncs a user from the API', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
Http::fake([
|
||||
@@ -142,16 +140,15 @@ it('syncs user from API', function () {
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new UserSyncService;
|
||||
$service->sync(1);
|
||||
(new UserSyncService)->sync(1);
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->url() === 'https://api.example.com/users/1';
|
||||
});
|
||||
Http::assertSent(fn (Request $request) =>
|
||||
$request->url() === 'https://api.example.com/users/1'
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
Test failure scenarios too:
|
||||
For example, fake a connection failure when testing the integration's failure path:
|
||||
|
||||
```php
|
||||
Http::fake([
|
||||
|
||||
@@ -1,27 +1,54 @@
|
||||
# Mail Best Practices
|
||||
|
||||
## Implement `ShouldQueue` on the Mailable Class
|
||||
## Queue Slow Mail Delivery
|
||||
|
||||
Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
|
||||
Implement `ShouldQueue` on a mailable when delivery should normally happen in the background. Laravel queues that mailable even when the call site uses `Mail::send()`.
|
||||
|
||||
## Use `afterCommit()` on Mailables Inside Transactions
|
||||
```php
|
||||
class OrderShipped extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
}
|
||||
```
|
||||
|
||||
A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
|
||||
Keep mail synchronous when the caller must know immediately whether delivery was accepted, or when no queue worker is available.
|
||||
|
||||
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
||||
## Dispatch Queued Mail After Commit
|
||||
|
||||
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
|
||||
A queued mailable dispatched during a database transaction can be processed before the transaction commits. Call `afterCommit()` on the mailable, or enable the queue connection's `after_commit` option, when the mail depends on committed records.
|
||||
|
||||
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
||||
```php
|
||||
Mail::to($user)->send(
|
||||
(new OrderShipped($order))->afterCommit()
|
||||
);
|
||||
```
|
||||
|
||||
Correct: `Mail::assertQueued(OrderShipped::class);`
|
||||
If the transaction rolls back, an after-commit mailable is not dispatched. This setting affects queued mail only; it does not defer synchronous delivery.
|
||||
|
||||
## Use Markdown Mailables for Transactional Emails
|
||||
## Assert the Delivery Mode
|
||||
|
||||
Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
|
||||
Use `Mail::assertQueued()` for queued mailables and `Mail::assertSent()` for synchronously sent mailables.
|
||||
|
||||
## Separate Content Tests from Sending Tests
|
||||
Incorrect for a mailable that implements `ShouldQueue`:
|
||||
|
||||
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
||||
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
||||
Don't mix them — it conflates concerns and makes tests brittle.
|
||||
```php
|
||||
Mail::assertSent(OrderShipped::class);
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```php
|
||||
Mail::assertQueued(OrderShipped::class);
|
||||
```
|
||||
|
||||
## Use Markdown Mailables When They Fit
|
||||
|
||||
Markdown mailables render HTML and plain-text versions from Laravel's mail components and support publishable themes. They are useful for conventional transactional messages, but a custom HTML and text pair may be more appropriate for a specialized design.
|
||||
|
||||
```bash
|
||||
php artisan make:mail OrderShipped --markdown=mail.orders.shipped
|
||||
```
|
||||
|
||||
## Separate Content and Delivery Tests
|
||||
|
||||
Test rendered content by instantiating the mailable and using assertions such as `assertSeeInHtml()` and `assertSeeInText()`. Test delivery separately with `Mail::fake()` and `assertSent()` or `assertQueued()` so failures identify the affected behavior.
|
||||
|
||||
@@ -2,76 +2,51 @@
|
||||
|
||||
## Generate Migrations with Artisan
|
||||
|
||||
Always use `php artisan make:migration` for consistent naming and timestamps.
|
||||
Use `php artisan make:migration` to generate the timestamped filename and migration structure.
|
||||
|
||||
Incorrect (manually created file):
|
||||
```php
|
||||
// database/migrations/posts_migration.php ← wrong naming, no timestamp
|
||||
```
|
||||
|
||||
Correct (Artisan-generated):
|
||||
```bash
|
||||
php artisan make:migration create_posts_table
|
||||
php artisan make:migration add_slug_to_posts_table
|
||||
```
|
||||
|
||||
## Use `constrained()` for Foreign Keys
|
||||
## Define Foreign-Key Constraints Deliberately
|
||||
|
||||
Automatic naming and referential integrity.
|
||||
Use `constrained()` when its naming conventions and default actions match the relationship. Specify the table or delete behavior when they do not.
|
||||
|
||||
```php
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
// Non-standard names
|
||||
$table->foreignId('author_id')->constrained('users');
|
||||
```
|
||||
|
||||
## Never Modify Deployed Migrations
|
||||
Do not add a duplicate single-column index without checking the database driver's treatment of foreign-key indexes and the indexes already created by the migration.
|
||||
|
||||
Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
|
||||
## Treat Deployed Migrations as Immutable
|
||||
|
||||
After a migration has run in a shared or production environment, create a new migration for subsequent changes. Editing the old file makes fresh installations differ from upgraded installations.
|
||||
|
||||
For a local migration that has not been shared or deployed, editing and rerunning it may be simpler.
|
||||
|
||||
## Design Indexes for Real Queries
|
||||
|
||||
Add indexes based on query patterns, selectivity, write cost, and the database's ability to use composite indexes. A column appearing in `WHERE`, `ORDER BY`, or `JOIN` does not automatically need its own index.
|
||||
|
||||
Declare each selected index in the schema migration that creates or changes the relevant table. Confirm important indexes with representative data and the database's query plan, and avoid redundant indexes whose leading columns duplicate an existing index without serving a distinct query. See the database performance and advanced query rules for index selection and column-order guidance.
|
||||
|
||||
## Stage Changes That Affect Existing Rows
|
||||
|
||||
Adding a required or unique column to a populated table often needs multiple deployment-safe steps. Add a nullable column, deploy code that can handle both states, backfill existing rows in bounded chunks, then add the required constraint or index after the data is valid.
|
||||
|
||||
Do not assume this migration is safe on a populated table:
|
||||
|
||||
Incorrect (editing a deployed migration):
|
||||
```php
|
||||
// 2024_01_01_create_posts_table.php — already in production
|
||||
$table->string('slug')->unique(); // ← added after deployment
|
||||
$table->string('slug')->unique();
|
||||
```
|
||||
|
||||
Correct (new migration to alter):
|
||||
```php
|
||||
// 2024_03_15_add_slug_to_posts_table.php
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->string('slug')->unique()->after('title');
|
||||
});
|
||||
```
|
||||
Large backfills are usually better implemented as an observable, restartable command or job than inside a schema migration. Small deterministic data changes may be reasonable in a migration when their locking, transaction, and deployment behavior is understood.
|
||||
|
||||
## Add Indexes in the Migration
|
||||
## Mirror Defaults Only When Unsaved Models Need Them
|
||||
|
||||
Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained();
|
||||
$table->string('status');
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->index();
|
||||
$table->string('status')->index();
|
||||
$table->timestamp('shipped_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
## Mirror Defaults in Model `$attributes`
|
||||
|
||||
When a column has a database default, mirror it in the model so new instances have correct values before saving.
|
||||
A database default is applied when a row is inserted, not when a model is instantiated. Mirror the value in the model's `$attributes` only when application code must observe that default before persistence, and keep both definitions synchronized.
|
||||
|
||||
```php
|
||||
// Migration
|
||||
@@ -83,39 +58,10 @@ protected $attributes = [
|
||||
];
|
||||
```
|
||||
|
||||
## Write Reversible `down()` Methods by Default
|
||||
## Make Rollbacks Honest
|
||||
|
||||
Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
|
||||
|
||||
```php
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->dropColumn('slug');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
|
||||
Implement `down()` when the change can be safely reversed. A rollback that drops populated columns or cannot restore transformed data is destructive even if it is syntactically reversible; document that limitation and prefer a forward-fix migration in production.
|
||||
|
||||
## Keep Migrations Focused
|
||||
|
||||
One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
|
||||
|
||||
Incorrect (partial failure creates unrecoverable state):
|
||||
```php
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table) { ... });
|
||||
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||
}
|
||||
```
|
||||
|
||||
Correct (separate migrations):
|
||||
```php
|
||||
// Migration 1: create_settings_table
|
||||
Schema::create('settings', function (Blueprint $table) { ... });
|
||||
|
||||
// Migration 2: seed_default_settings
|
||||
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||
```
|
||||
Keep each migration small enough to reason about, deploy, and reverse. Separate long-running backfills from schema changes when doing so reduces locks and supports phased deployment, but do not split related operations merely to enforce a blanket separation between data definition and data manipulation.
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
# Queue & Job Best Practices
|
||||
# Queue and Job Best Practices
|
||||
|
||||
## Set `retry_after` Greater Than `timeout`
|
||||
## Keep Reservation Time Longer Than Execution Time
|
||||
|
||||
If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
|
||||
For queue drivers that use Laravel's `retry_after` setting, configure it to exceed the longest worker or job timeout by a safety margin. When a reservation expires, another worker can reserve the same job while the first process is still running. Keep the worker's `--timeout` several seconds shorter than `retry_after`.
|
||||
|
||||
Incorrect (`retry_after` ≤ `timeout`):
|
||||
```php
|
||||
class ProcessReport implements ShouldQueue
|
||||
{
|
||||
public $timeout = 120;
|
||||
}
|
||||
// Job
|
||||
public $timeout = 120;
|
||||
|
||||
// config/queue.php — retry_after: 90 ← job retried while still running!
|
||||
// config/queue.php for the connection
|
||||
'retry_after' => 150,
|
||||
```
|
||||
|
||||
Correct (`retry_after` > `timeout`):
|
||||
```php
|
||||
class ProcessReport implements ShouldQueue
|
||||
{
|
||||
public $timeout = 120;
|
||||
}
|
||||
Amazon Simple Queue Service uses its visibility timeout instead of Laravel's `retry_after`; configure that timeout at the queue level. Because workers can also stop after side effects but before acknowledging a job, make important jobs idempotent even with correct timeout settings.
|
||||
|
||||
// config/queue.php — retry_after: 180 ← safely longer than any job timeout
|
||||
```
|
||||
## Back Off Transient Failures
|
||||
|
||||
## Use Exponential Backoff
|
||||
Use progressively longer delays when a dependency needs time to recover. Do not retry permanent validation or business-rule failures.
|
||||
|
||||
Use progressively longer delays between retries to avoid hammering failing services.
|
||||
|
||||
Incorrect (fixed retry interval):
|
||||
```php
|
||||
class SyncWithStripe implements ShouldQueue
|
||||
{
|
||||
public $tries = 3;
|
||||
// Default: retries immediately, overwhelming the API
|
||||
}
|
||||
```
|
||||
public $tries = 4;
|
||||
|
||||
Correct (exponential backoff):
|
||||
```php
|
||||
class SyncWithStripe implements ShouldQueue
|
||||
{
|
||||
public $tries = 3;
|
||||
public $backoff = [1, 5, 10];
|
||||
}
|
||||
```
|
||||
|
||||
## Implement `ShouldBeUnique`
|
||||
Rate-limiting and exception-throttling middleware can release jobs back to the queue. Released attempts may still count toward the maximum attempt limit, so configure `$tries` or `retryUntil()` to allow the intended retry window.
|
||||
|
||||
Prevent duplicate job processing.
|
||||
## Use Unique Jobs for Dispatch Deduplication
|
||||
|
||||
Implement `ShouldBeUnique` when only one queued instance of a logical job should exist. Uniqueness uses a cache lock and is not a substitute for idempotent processing or a database constraint.
|
||||
|
||||
```php
|
||||
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
public $uniqueFor = 3600;
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return $this->order->id;
|
||||
return (string) $this->order->id;
|
||||
}
|
||||
|
||||
public $uniqueFor = 3600;
|
||||
}
|
||||
```
|
||||
|
||||
## Always Implement `failed()`
|
||||
All dispatching processes must use a shared cache that supports locks. Unique-job constraints do not apply to jobs within batches.
|
||||
|
||||
Handle errors explicitly — don't rely on silent failure.
|
||||
Use `ShouldBeUniqueUntilProcessing` only when the lock should be released immediately before processing begins, allowing another instance to be dispatched while the first is running:
|
||||
|
||||
```php
|
||||
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Handle Terminal Failure When Needed
|
||||
|
||||
Implement `failed()` when the application must update state, alert an operator, or record domain-specific context after all attempts are exhausted. Logging every failure in each job may duplicate the queue system's failure reporting.
|
||||
|
||||
Laravel invokes `failed()` on a new job instance, so mutations made to the job during `handle()` are not available there.
|
||||
|
||||
```php
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
$this->podcast->update(['status' => 'failed']);
|
||||
Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
|
||||
|
||||
Log::error('Podcast processing failed', [
|
||||
'podcast_id' => $this->podcast->id,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit External API Calls in Jobs
|
||||
## Rate Limit External Calls
|
||||
|
||||
Use `RateLimited` middleware to throttle jobs calling third-party APIs.
|
||||
Use queue middleware such as `RateLimited` when jobs share a third-party API quota. Define the named limiter and choose release delays and attempt limits together.
|
||||
|
||||
```php
|
||||
public function middleware(): array
|
||||
@@ -85,60 +85,33 @@ public function middleware(): array
|
||||
}
|
||||
```
|
||||
|
||||
## Batch Related Jobs
|
||||
## Batch Jobs for Group Coordination
|
||||
|
||||
Use `Bus::batch()` when jobs should succeed or fail together.
|
||||
Use `Bus::batch()` to monitor a group of jobs and run callbacks when the batch completes or encounters failures. A batch is not a database transaction: completed jobs are not rolled back when another job fails. By default, one failed job cancels the batch; call `allowFailures()` only when partial failure is acceptable.
|
||||
|
||||
```php
|
||||
Bus::batch([
|
||||
new ImportCsvChunk($chunk1),
|
||||
new ImportCsvChunk($chunk2),
|
||||
])
|
||||
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
|
||||
->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
|
||||
->dispatch();
|
||||
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
|
||||
->catch(fn (Batch $batch, Throwable $exception) => Log::error('Import batch failed', [
|
||||
'exception' => $exception,
|
||||
]))
|
||||
->dispatch();
|
||||
```
|
||||
|
||||
## `retryUntil()` Needs `$tries = 0`
|
||||
## Configure Time-Based Retry Limits Deliberately
|
||||
|
||||
When using time-based retry limits, set `$tries = 0` to avoid premature failure.
|
||||
Use `retryUntil()` as the time-based alternative to a maximum attempt count. Laravel may attempt the job any number of times until this deadline, subject to other failure conditions such as maximum exceptions. The method takes precedence over attempt-based limits, so setting `$tries = 0` is not required.
|
||||
|
||||
```php
|
||||
public $tries = 0;
|
||||
|
||||
public function retryUntil(): \DateTimeInterface
|
||||
public function retryUntil(): DateTimeInterface
|
||||
{
|
||||
return now()->addHours(4);
|
||||
}
|
||||
```
|
||||
|
||||
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
|
||||
## Use Horizon for Redis Queue Operations
|
||||
|
||||
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
|
||||
|
||||
```php
|
||||
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
|
||||
{
|
||||
// Lock releases when processing begins, not when it finishes
|
||||
}
|
||||
```
|
||||
|
||||
## Use Horizon for Complex Queue Scenarios
|
||||
|
||||
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
||||
|
||||
```php
|
||||
// config/horizon.php
|
||||
'environments' => [
|
||||
'production' => [
|
||||
'supervisor-1' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['high', 'default', 'low'],
|
||||
'balance' => 'auto',
|
||||
'minProcesses' => 1,
|
||||
'maxProcesses' => 10,
|
||||
'tries' => 3,
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
Laravel Horizon provides monitoring, balancing, metrics, and supervisor configuration for Redis queues. It does not support non-Redis queue drivers.
|
||||
|
||||
@@ -1,99 +1,106 @@
|
||||
# Routing & Controllers Best Practices
|
||||
# Routing and Controller Best Practices
|
||||
|
||||
## Use Implicit Route Model Binding
|
||||
|
||||
Let Laravel resolve models automatically from route parameters.
|
||||
Let Laravel resolve models from route parameters when the default lookup and missing-model behavior fit the endpoint.
|
||||
|
||||
Instead of manual lookup:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function show(int $id)
|
||||
public function show(int $id): View
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
return view('posts.show', ['post' => $post]);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
Use route model binding:
|
||||
|
||||
```php
|
||||
public function show(Post $post)
|
||||
public function show(Post $post): View
|
||||
{
|
||||
return view('posts.show', ['post' => $post]);
|
||||
}
|
||||
```
|
||||
|
||||
## Use Scoped Bindings for Nested Resources
|
||||
## Scope Nested Bindings
|
||||
|
||||
Enforce parent-child relationships automatically.
|
||||
Use scoped bindings when a nested resource must belong to its parent. This constrains model resolution; it does not replace authorization.
|
||||
|
||||
```php
|
||||
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
|
||||
// $post is automatically scoped to $user
|
||||
// The resolved post belongs to the resolved user.
|
||||
})->scopeBindings();
|
||||
```
|
||||
|
||||
## Use Resource Controllers
|
||||
## Use Resource Routes for Resourceful Actions
|
||||
|
||||
Use `Route::resource()` or `apiResource()` for RESTful endpoints.
|
||||
Use `Route::resource()` or `Route::apiResource()` when the endpoint follows Laravel's resource-controller actions. Define explicit routes when the behavior does not fit that vocabulary.
|
||||
|
||||
```php
|
||||
Route::resource('posts', PostController::class);
|
||||
// In routes/api.php — the /api prefix is applied automatically
|
||||
Route::apiResource('posts', Api\PostController::class);
|
||||
|
||||
// Alternatively, for an API-only resource:
|
||||
Route::apiResource('posts', ApiPostController::class);
|
||||
```
|
||||
|
||||
## Keep Controllers Thin
|
||||
`apiResource()` omits the HTML-oriented `create` and `edit` routes. It does not itself add an `/api` prefix; that prefix comes from the application's API route configuration.
|
||||
|
||||
Aim for under 10 lines per method. Extract business logic to action or service classes.
|
||||
## Organize Controllers Around Resources
|
||||
|
||||
As a general default, organize each controller around one resource and use Laravel's standard resource actions: `index`, `show`, `create`, `store`, `edit`, `update`, and `destroy`. This keeps routes predictable and prevents controllers from accumulating unrelated behavior.
|
||||
|
||||
When a controller needs a custom action such as `publish`, `approve`, or `archive`, first consider whether that behavior represents a separate resource. A focused resource controller gives the behavior its own authorization, validation, and middleware boundary.
|
||||
|
||||
Custom action on the primary controller:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function store(Request $request)
|
||||
Route::post('/podcasts/{podcast}/publish', [PodcastController::class, 'publish']);
|
||||
```
|
||||
|
||||
The published podcast modeled as a resource:
|
||||
|
||||
```php
|
||||
Route::post('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'store'])
|
||||
->name('published-podcasts.store');
|
||||
|
||||
Route::delete('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'destroy'])
|
||||
->name('published-podcasts.destroy');
|
||||
```
|
||||
|
||||
```php
|
||||
class PublishedPodcastController extends Controller
|
||||
{
|
||||
$validated = $request->validate([...]);
|
||||
if ($request->hasFile('image')) {
|
||||
$request->file('image')->move(public_path('images'));
|
||||
public function store(Podcast $podcast): RedirectResponse
|
||||
{
|
||||
$podcast->publish();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Podcast $podcast): RedirectResponse
|
||||
{
|
||||
$podcast->unpublish();
|
||||
|
||||
return back();
|
||||
}
|
||||
$post = Post::create($validated);
|
||||
$post->tags()->sync($validated['tags']);
|
||||
event(new PostCreated($post));
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
Treat a custom verb as a design signal, not proof that another controller is required. Use query parameters for simple filtering, and keep an explicit action route when modeling the operation as a resource would obscure the domain or conflict with established project conventions.
|
||||
|
||||
## Keep Controllers Focused on HTTP Concerns
|
||||
|
||||
Controllers should coordinate HTTP input, authorization, validation, an application operation, and the response. Extract substantial or reusable business logic, but do not introduce an action or service merely to satisfy an arbitrary line limit.
|
||||
|
||||
```php
|
||||
public function store(StorePostRequest $request, CreatePostAction $create)
|
||||
public function store(StorePostRequest $request, CreatePostAction $create): RedirectResponse
|
||||
{
|
||||
$post = $create->execute($request->validated());
|
||||
$post = $create->handle($request->validated());
|
||||
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
## Type-Hint Form Requests
|
||||
|
||||
Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'max:255'],
|
||||
'body' => ['required'],
|
||||
]);
|
||||
|
||||
Post::create($validated);
|
||||
|
||||
return redirect()->route('posts.index');
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function store(StorePostRequest $request): RedirectResponse
|
||||
{
|
||||
Post::create($request->validated());
|
||||
|
||||
return redirect()->route('posts.index');
|
||||
}
|
||||
```
|
||||
A form request can perform validation and authorization before the controller runs. Do not repeat its rules in the controller. Keep simple, endpoint-specific validation inline when extraction would not improve reuse or clarity; see the validation rules for detailed guidance.
|
||||
|
||||
@@ -1,32 +1,50 @@
|
||||
# Task Scheduling Best Practices
|
||||
|
||||
## Use `withoutOverlapping()` on Variable-Duration Tasks
|
||||
## Prevent Unwanted Overlap
|
||||
|
||||
Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
|
||||
|
||||
## Use `onOneServer()` on Multi-Server Deployments
|
||||
|
||||
Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
|
||||
|
||||
## Use `runInBackground()` for Concurrent Long Tasks
|
||||
|
||||
By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
|
||||
|
||||
## Use `environments()` to Restrict Tasks
|
||||
|
||||
Prevent accidental execution of production-only tasks (billing, reporting) on staging.
|
||||
Use `withoutOverlapping()` when a second run must not begin while the previous run holds the lock. This is appropriate for variable-duration tasks that are not safe to run concurrently.
|
||||
|
||||
```php
|
||||
Schedule::command('billing:charge')->monthly()->environments(['production']);
|
||||
Schedule::command('reports:generate')
|
||||
->everyFifteenMinutes()
|
||||
->withoutOverlapping(30);
|
||||
```
|
||||
|
||||
## Use `takeUntilTimeout()` for Time-Bounded Processing
|
||||
The optional value is the lock expiration time in minutes, not the task timeout. Choose it carefully: the default is 24 hours, stale locks can be cleared with `php artisan schedule:clear-cache`, and an expiration that is too short can permit overlap while the first task still runs. The task itself should still tolerate retries and partial execution where practical.
|
||||
|
||||
A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
|
||||
## Run a Task on One Server
|
||||
|
||||
## Use Schedule Groups for Shared Configuration
|
||||
Use `onOneServer()` when only one scheduler node should run an eligible task. Scheduler nodes must use the same default cache store, and that store must support atomic locks. Supported stores include `database`, `memcached`, `dynamodb`, and `redis`.
|
||||
|
||||
Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
|
||||
```php
|
||||
Schedule::command('billing:charge')->daily()->onOneServer();
|
||||
```
|
||||
|
||||
Name scheduled closures before applying `onOneServer()`, especially when scheduling the same closure with different parameters, so each task has a distinct lock identity.
|
||||
|
||||
## Run Eligible Commands in the Background
|
||||
|
||||
Tasks due at the same time run sequentially by default. Use `runInBackground()` when an independent, long-running scheduled command should not delay later tasks.
|
||||
|
||||
```php
|
||||
Schedule::command('analytics:process')->hourly()->runInBackground();
|
||||
```
|
||||
|
||||
Laravel restricts `runInBackground()` to tasks scheduled with `command()` and `exec()`; it is not available for scheduled closures. Ensure background processes have appropriate logging and failure monitoring.
|
||||
|
||||
## Restrict Tasks by Environment
|
||||
|
||||
Use `environments()` when a task should run only in named application environments. Treat this as an operational safeguard, not an authorization control.
|
||||
|
||||
```php
|
||||
Schedule::command('billing:charge')
|
||||
->monthly()
|
||||
->environments(['production']);
|
||||
```
|
||||
|
||||
## Group Shared Configuration
|
||||
|
||||
Use schedule groups when several tasks genuinely share frequency or constraints.
|
||||
|
||||
```php
|
||||
Schedule::daily()
|
||||
@@ -37,3 +55,7 @@ Schedule::daily()
|
||||
Schedule::command('emails:prune');
|
||||
});
|
||||
```
|
||||
|
||||
## Bound Work Inside the Task
|
||||
|
||||
The scheduler does not provide a `takeUntilTimeout()` event method or terminate arbitrary tasks at a deadline. Bound work in the command or job itself by processing finite chunks, checking a deadline, or dispatching queue jobs with suitable timeouts. Use operating-system or process controls when hard termination is required.
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
# Security Best Practices
|
||||
|
||||
## Mass Assignment Protection
|
||||
## Control Mass Assignment
|
||||
|
||||
Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
|
||||
Define `$fillable` when a model is populated from request-derived arrays, or deliberately guard attributes by another consistent model convention. Laravel models guard all attributes by default; `$guarded = []` opts out of that protection.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class User extends Model
|
||||
{
|
||||
protected $guarded = []; // All fields are mass assignable
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class User extends Model
|
||||
{
|
||||
@@ -24,82 +15,71 @@ class User extends Model
|
||||
}
|
||||
```
|
||||
|
||||
Never use `$guarded = []` on models that accept user input.
|
||||
Do not pass untrusted request data to a model with `$guarded = []`. Mass-assignment protection controls which attributes `create()`, `fill()`, and `update()` may set; it does not validate values or authorize the operation.
|
||||
|
||||
## Authorize Every Action
|
||||
## Authorize Protected Actions
|
||||
|
||||
Use policies or gates in controllers. Never skip authorization.
|
||||
Use policies, gates, or form request authorization for actions that depend on the current user's permissions. Authentication alone does not establish permission, and validation is not authorization.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function update(UpdatePostRequest $request, Post $post)
|
||||
{
|
||||
$post->update($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function update(UpdatePostRequest $request, Post $post)
|
||||
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
|
||||
{
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$post->update($request->validated());
|
||||
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
Or via Form Request:
|
||||
Authorization may instead live in the form request:
|
||||
|
||||
```php
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('update', $this->route('post'));
|
||||
return $this->user()?->can('update', $this->route('post')) ?? false;
|
||||
}
|
||||
```
|
||||
|
||||
## Prevent SQL Injection
|
||||
Public actions intentionally available to everyone do not need a redundant authorization check.
|
||||
|
||||
Always use parameter binding. Never interpolate user input into queries.
|
||||
## Bind Query Parameters
|
||||
|
||||
Use Eloquent, the query builder, or explicit bindings instead of interpolating untrusted values into Structured Query Language (SQL). Bindings protect values, not identifiers such as column names or sort directions; map user-selected identifiers to an allow-list.
|
||||
|
||||
Incorrect:
|
||||
|
||||
```php
|
||||
DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```php
|
||||
User::where('name', $request->name)->get();
|
||||
|
||||
// Raw expressions with bindings
|
||||
User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
|
||||
User::whereRaw('LOWER(name) = ?', [$request->string('name')->lower()->toString()])->get();
|
||||
```
|
||||
|
||||
## Escape Output to Prevent XSS
|
||||
## Escape Output in Its Context
|
||||
|
||||
Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
|
||||
Blade's `{{ }}` syntax HTML-escapes output. Use `{!! !!}` only for content that has been sanitized for the exact HTML context in which it is rendered. Escaping rules differ for HTML, URLs, JavaScript, and Cascading Style Sheets.
|
||||
|
||||
Incorrect for untrusted content:
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
{!! $user->bio !!}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```blade
|
||||
{{ $user->bio }}
|
||||
```
|
||||
|
||||
## CSRF Protection
|
||||
## Apply Cross-Site Request Forgery Protection
|
||||
|
||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
|
||||
Include `@csrf` in state-changing Blade forms handled by Laravel's `web` middleware. Routes intentionally excluded from cross-site request forgery (CSRF) verification, such as validated third-party webhooks, need their own authenticity check.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
<form method="POST" action="/posts">
|
||||
<input type="text" name="title">
|
||||
</form>
|
||||
```
|
||||
|
||||
Correct:
|
||||
```blade
|
||||
<form method="POST" action="/posts">
|
||||
@csrf
|
||||
@@ -107,21 +87,27 @@ Correct:
|
||||
</form>
|
||||
```
|
||||
|
||||
## Rate Limit Auth and API Routes
|
||||
Inertia applications commonly use Axios, which returns the encrypted `XSRF-TOKEN` cookie in the `X-XSRF-TOKEN` header. Confirm equivalent configuration when using another HTTP client. Do not disable CSRF protection merely to fix a token mismatch.
|
||||
|
||||
Apply `throttle` middleware to authentication and API routes.
|
||||
## Rate Limit Sensitive Endpoints
|
||||
|
||||
Apply suitable rate limits to login attempts, password recovery, verification messages, and expensive or abuse-prone application programming interface (API) routes. Choose the limiter key deliberately; an Internet Protocol (IP) address alone can unfairly group users behind a shared network, while an account identifier alone can enable targeted denial of service.
|
||||
|
||||
```php
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->ip());
|
||||
return Limit::perMinute(5)->by(Str::transliterate(
|
||||
Str::lower($request->string('email')).'|'.$request->ip()
|
||||
));
|
||||
});
|
||||
|
||||
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
||||
```
|
||||
|
||||
## Validate File Uploads
|
||||
Rate limiting reduces abuse; it does not replace authentication, authorization, or upstream denial-of-service protection.
|
||||
|
||||
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
|
||||
## Validate and Store Uploads Safely
|
||||
|
||||
Validate expected content type, dimensions where relevant, and size. Laravel's `mimes` rule reads the file contents and guesses a Multipurpose Internet Mail Extensions (MIME) type corresponding to the listed extensions; it does not validate the user-assigned filename extension. The `extensions` rule checks that extension and should not be used by itself.
|
||||
|
||||
```php
|
||||
public function rules(): array
|
||||
@@ -132,56 +118,28 @@ public function rules(): array
|
||||
}
|
||||
```
|
||||
|
||||
Store with generated filenames:
|
||||
Use Laravel's storage methods to generate a filename, and store untrusted files outside a publicly executable location. Public files can require additional controls, such as image re-encoding, content-disposition headers, and explicit blocking of active formats.
|
||||
|
||||
```php
|
||||
$path = $request->file('avatar')->store('avatars', 'public');
|
||||
$path = $request->file('avatar')->store('avatars');
|
||||
```
|
||||
|
||||
## Keep Secrets Out of Code
|
||||
## Keep Secrets Out of Application Code
|
||||
|
||||
Never commit `.env`. Access secrets via `config()` only.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$key = env('API_KEY');
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
// config/services.php
|
||||
'api_key' => env('API_KEY'),
|
||||
|
||||
// In application code
|
||||
$key = config('services.api_key');
|
||||
```
|
||||
Do not commit populated environment files or hard-code credentials. Read environment variables in configuration files, then use `config()` in application code so configuration caching works correctly. See the configuration rules for encrypted environment files and external secret stores.
|
||||
|
||||
## Audit Dependencies
|
||||
|
||||
Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
|
||||
Run `composer audit` regularly and in continuous integration. Review findings for exploitability and update or mitigate affected packages promptly.
|
||||
|
||||
```bash
|
||||
composer audit
|
||||
```
|
||||
|
||||
## Encrypt Sensitive Database Fields
|
||||
## Encrypt Sensitive Attributes When Appropriate
|
||||
|
||||
Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
|
||||
Use an `encrypted` cast for sensitive values that must be recoverable, and use `$hidden` to omit them from array and JavaScript Object Notation (JSON) serialization. Hidden attributes remain accessible in PHP, and encryption does not replace access control. Encrypted values cannot be meaningfully queried and should use a `TEXT` or larger column because ciphertext length is variable.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class Integration extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_key' => 'string',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class Integration extends Model
|
||||
{
|
||||
|
||||
@@ -1,125 +1,110 @@
|
||||
# Conventions & Style
|
||||
# Convention and Style Best Practices
|
||||
|
||||
## Follow Laravel Naming Conventions
|
||||
## Follow Project Naming Conventions
|
||||
|
||||
| What | Convention | Good | Bad |
|
||||
|------|-----------|------|-----|
|
||||
| Controller | singular | `ArticleController` | `ArticlesController` |
|
||||
| Model | singular | `User` | `Users` |
|
||||
| Table | plural, snake_case | `article_comments` | `articleComments` |
|
||||
| Pivot table | singular alphabetical | `article_user` | `user_article` |
|
||||
| Column | snake_case, no model name | `meta_title` | `article_meta_title` |
|
||||
| Foreign key | singular model + `_id` | `article_id` | `articles_id` |
|
||||
| Route | plural | `articles/1` | `article/1` |
|
||||
| Route name | snake_case with dots | `users.show_active` | `users.show-active` |
|
||||
| Method | camelCase | `getAll` | `get_all` |
|
||||
| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
|
||||
| Collection | descriptive, plural | `$activeUsers` | `$data` |
|
||||
| Object | descriptive, singular | `$activeUser` | `$users` |
|
||||
| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
|
||||
| Config | snake_case | `google_calendar.php` | `googleCalendar.php` |
|
||||
| Enum | singular | `UserType` | `UserTypes` |
|
||||
Prefer Laravel's conventions in new code, but preserve an established project convention unless a coordinated rename is worthwhile.
|
||||
|
||||
## Prefer Shorter Readable Syntax
|
||||
| Element | Convention | Example |
|
||||
| --- | --- | --- |
|
||||
| Controller | Singular resource name | `ArticleController` |
|
||||
| Model | Singular StudlyCase | `User` |
|
||||
| Table | Plural snake_case | `article_comments` |
|
||||
| Pivot table | Singular model names in alphabetical order, in snake_case | `article_user` |
|
||||
| Column | snake_case | `meta_title` |
|
||||
| Conventional foreign key | Singular model name plus `_id`, in snake_case | `article_id` |
|
||||
| Resource URI | Plural resource | `articles/1` |
|
||||
| Route name | Dotted segments; snake_case within a segment when needed | `users.show_active` |
|
||||
| Method | camelCase | `getAll` |
|
||||
| Variable | camelCase | `$articlesWithAuthor` |
|
||||
| Collection | Descriptive and plural | `$activeUsers` |
|
||||
| Object | Descriptive and singular | `$activeUser` |
|
||||
| View | kebab-case | `show-filtered.blade.php` |
|
||||
| Configuration file | snake_case | `google_calendar.php` |
|
||||
| Enumeration | Singular StudlyCase | `UserType` |
|
||||
|
||||
| Verbose | Shorter |
|
||||
|---------|---------|
|
||||
## Prefer Clear, Idiomatic Syntax
|
||||
|
||||
Use Laravel helpers and query methods when they communicate intent more directly. Do not shorten code when the result is ambiguous or loses useful type information.
|
||||
|
||||
| More verbose | Idiomatic alternative |
|
||||
| --- | --- |
|
||||
| `Session::get('cart')` | `session('cart')` |
|
||||
| `$request->session()->get('cart')` | `session('cart')` |
|
||||
| `$request->input('name')` | `$request->name` |
|
||||
| `return Redirect::back()` | `return back()` |
|
||||
| `Carbon::now()` | `now()` |
|
||||
| `App::make('Class')` | `app('Class')` |
|
||||
| `->where('column', '=', 1)` | `->where('column', 1)` |
|
||||
| `->orderBy('created_at', 'desc')` | `->latest()` |
|
||||
| `->orderBy('created_at', 'asc')` | `->oldest()` |
|
||||
| `->first()->name` | `->value('name')` |
|
||||
| `->first()?->name` | `->value('name')` when only that value is needed |
|
||||
|
||||
## Use Laravel String & Array Helpers
|
||||
Use typed request accessors such as `$request->string()`, `$request->integer()`, and `$request->boolean()` when their coercion matches the operation.
|
||||
|
||||
Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them.
|
||||
## Use Utilities When They Clarify Intent
|
||||
|
||||
Laravel's `Str`, `Arr`, `Number`, and `Uri` utilities provide expressive operations and framework-consistent behavior. Prefer them when they are clearer or safer than an equivalent PHP operation, not as an unconditional replacement for every built-in function.
|
||||
|
||||
Strings — use `Str` and fluent `Str::of()` over raw PHP:
|
||||
```php
|
||||
// Incorrect
|
||||
$slug = strtolower(str_replace(' ', '-', $title));
|
||||
$short = substr($text, 0, 100) . '...';
|
||||
$class = substr(strrchr('App\Models\User', '\\'), 1);
|
||||
|
||||
// Correct
|
||||
$slug = Str::slug($title);
|
||||
$short = Str::limit($text, 100);
|
||||
$class = class_basename('App\Models\User');
|
||||
```
|
||||
|
||||
Fluent strings — chain operations for complex transformations:
|
||||
```php
|
||||
// Incorrect
|
||||
$result = strtolower(trim(str_replace('_', '-', $input)));
|
||||
|
||||
// Correct
|
||||
$class = class_basename(User::class);
|
||||
$result = Str::of($input)->trim()->replace('_', '-')->lower();
|
||||
```
|
||||
|
||||
Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`.
|
||||
Use `Arr` for dot notation and common transformations:
|
||||
|
||||
Arrays — use `Arr` over raw PHP:
|
||||
```php
|
||||
// Incorrect
|
||||
$name = isset($array['user']['name']) ? $array['user']['name'] : 'default';
|
||||
|
||||
// Correct
|
||||
$name = Arr::get($array, 'user.name', 'default');
|
||||
$public = Arr::only($attributes, ['name', 'email']);
|
||||
```
|
||||
|
||||
Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`.
|
||||
Use `Number` for localized display formatting rather than values that will be stored or calculated:
|
||||
|
||||
Numbers — use `Number` for display formatting:
|
||||
```php
|
||||
Number::format(1000000); // "1,000,000"
|
||||
Number::currency(1500, 'USD'); // "$1,500.00"
|
||||
Number::abbreviate(1000000); // "1M"
|
||||
Number::fileSize(1024 * 1024); // "1 MB"
|
||||
Number::percentage(75.5); // "75.5%"
|
||||
Number::format(1000000);
|
||||
Number::currency(1500, 'USD');
|
||||
Number::fileSize(1024 * 1024);
|
||||
```
|
||||
|
||||
URIs — use `Uri` for URL manipulation:
|
||||
Use `Uri` when constructing or transforming a uniform resource identifier (URI) benefits from a structured API:
|
||||
|
||||
```php
|
||||
$uri = Uri::of('https://example.com/search')
|
||||
->withQuery(['q' => 'laravel', 'page' => 1]);
|
||||
```
|
||||
|
||||
Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining.
|
||||
Check the documentation for the Laravel version supported by the project before using newer utility classes or methods.
|
||||
|
||||
Use `search-docs` for the full list of available methods — these helpers are extensive.
|
||||
## Keep Presentation Code Maintainable
|
||||
|
||||
## No Inline JS/CSS in Blade
|
||||
Prefer the project's asset pipeline, components, and existing conventions for substantial JavaScript and Cascading Style Sheets (CSS). Small page-specific scripts or styles can be reasonable in Blade layouts or stacks; avoid mixing large behavior and style blocks into templates.
|
||||
|
||||
Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes.
|
||||
Pass server data with an encoding mechanism appropriate to its context. For example, Blade's `Js::from()` safely formats data for JavaScript:
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
let article = `{{ json_encode($article) }}`;
|
||||
<script>
|
||||
const article = {{ Js::from($article) }};
|
||||
</script>
|
||||
```
|
||||
|
||||
Correct:
|
||||
```blade
|
||||
<button class="js-fav-article" data-article='@json($article)'>{{ $article->name }}</button>
|
||||
```
|
||||
Data attributes are useful for small scalar values, but serializing a large model into an attribute can expose unnecessary fields and complicate escaping.
|
||||
|
||||
Pass data to JS via data attributes or use a dedicated PHP-to-JS package.
|
||||
## Write Comments That Explain Why
|
||||
|
||||
## No Unnecessary Comments
|
||||
Prefer clear names and small units of code over comments that merely restate an operation. Add concise comments for non-obvious constraints, tradeoffs, workarounds, regular expressions, or external behavior that the code cannot express by itself. Keep comments accurate when behavior changes.
|
||||
|
||||
Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected.
|
||||
Unhelpful:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
// Check if there are any joins
|
||||
if (count((array) $builder->getQuery()->joins) > 0)
|
||||
// Check whether the query has joins.
|
||||
if (count((array) $builder->getQuery()->joins) > 0) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
Clearer:
|
||||
|
||||
```php
|
||||
if ($this->hasJoins())
|
||||
if ($this->hasJoins()) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# Testing Best Practices
|
||||
|
||||
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
||||
|
||||
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
|
||||
|
||||
## Use Model Assertions Over Raw Database Assertions
|
||||
|
||||
Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
|
||||
|
||||
Correct: `$this->assertModelExists($user);`
|
||||
|
||||
More expressive, type-safe, and fails with clearer messages.
|
||||
|
||||
## Use Factory States and Sequences
|
||||
|
||||
Named states make tests self-documenting. Sequences eliminate repetitive setup.
|
||||
|
||||
Incorrect: `User::factory()->create(['email_verified_at' => null]);`
|
||||
|
||||
Correct: `User::factory()->unverified()->create();`
|
||||
|
||||
## Use `Exceptions::fake()` to Assert Exception Reporting
|
||||
|
||||
Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
|
||||
|
||||
## Call `Event::fake()` After Factory Setup
|
||||
|
||||
Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
|
||||
|
||||
Incorrect: `Event::fake(); $user = User::factory()->create();`
|
||||
|
||||
Correct: `$user = User::factory()->create(); Event::fake();`
|
||||
|
||||
## Use `recycle()` to Share Relationship Instances Across Factories
|
||||
|
||||
Without `recycle()`, nested factories create separate instances of the same conceptual entity.
|
||||
|
||||
```php
|
||||
Ticket::factory()
|
||||
->recycle(Airline::factory()->create())
|
||||
->create();
|
||||
```
|
||||
@@ -1,75 +1,89 @@
|
||||
# Validation & Forms Best Practices
|
||||
# Validation and Forms Best Practices
|
||||
|
||||
## Use Form Request Classes
|
||||
## Extract Validation When It Improves the Boundary
|
||||
|
||||
Extract validation from controllers into dedicated Form Request classes.
|
||||
Use a form request when validation or authorization is substantial, reused, or clearer outside the controller. Inline `$request->validate()` remains appropriate for a small, endpoint-specific rule set.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function store(Request $request)
|
||||
public function store(StorePostRequest $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'required|max:255',
|
||||
'body' => 'required',
|
||||
]);
|
||||
$post = Post::create($request->validated());
|
||||
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function store(StorePostRequest $request)
|
||||
{
|
||||
Post::create($request->validated());
|
||||
}
|
||||
```
|
||||
A form request's `authorize()` method can enforce access to the operation. Validation establishes the shape and values of input; it does not itself authorize the user.
|
||||
|
||||
## Array vs. String Notation for Rules
|
||||
## Prefer Readable Rule Syntax
|
||||
|
||||
Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
|
||||
Array syntax composes cleanly with rule objects and avoids delimiter issues. Prefer it in new code when it improves readability, while following a consistent local style.
|
||||
|
||||
```php
|
||||
// Preferred for new code
|
||||
'email' => ['required', 'email', Rule::unique('users')],
|
||||
```
|
||||
|
||||
// Follow existing convention if the project uses string notation
|
||||
String syntax remains valid for simple rules:
|
||||
|
||||
```php
|
||||
'email' => 'required|email|unique:users',
|
||||
```
|
||||
|
||||
## Always Use `validated()`
|
||||
## Use Only Intended Validated Data
|
||||
|
||||
Get only validated data. Never use `$request->all()` for mass operations.
|
||||
Use `validated()` or `safe()` instead of `$request->all()` when passing request data onward. Then select the fields intended for the operation when the validation rules also cover control fields or nested data.
|
||||
|
||||
Unsafe:
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Post::create($request->all());
|
||||
```
|
||||
|
||||
Correct:
|
||||
Preferred:
|
||||
|
||||
```php
|
||||
Post::create($request->validated());
|
||||
$post = Post::create($request->safe()->only(['title', 'body']));
|
||||
```
|
||||
|
||||
## Use `Rule::when()` for Conditional Validation
|
||||
Validated data is not automatically safe for mass assignment. Keep model `$fillable` or `$guarded` rules aligned with the operation, and never add a sensitive attribute to validation merely to make mass assignment convenient.
|
||||
|
||||
## Express Conditional Rules Clearly
|
||||
|
||||
Use conditional rules such as `Rule::when()`, `required_if`, or `exclude_unless` when they make the condition explicit. Choose the simplest form that remains easy to test.
|
||||
|
||||
```php
|
||||
'company_name' => [
|
||||
Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::when(
|
||||
$this->input('account_type') === 'business',
|
||||
['required'],
|
||||
['nullable'],
|
||||
),
|
||||
],
|
||||
```
|
||||
|
||||
## Use the `after()` Method for Custom Validation
|
||||
## Add Cross-Field Validation After Base Rules
|
||||
|
||||
Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
|
||||
Use a form request's `after()` method for validation that depends on multiple fields or application state. Avoid expensive queries when prerequisite fields have already failed validation.
|
||||
|
||||
```php
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator) {
|
||||
if ($this->quantity > Product::find($this->product_id)?->stock) {
|
||||
if ($validator->errors()->hasAny(['product_id', 'quantity'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$stock = Product::find($this->integer('product_id'))?->stock;
|
||||
|
||||
if ($stock !== null && $this->integer('quantity') > $stock) {
|
||||
$validator->errors()->add('quantity', 'Not enough stock.');
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Validation against mutable state does not prevent a race between validation and persistence. Enforce inventory, uniqueness, and similar invariants with database constraints, atomic updates, or a database transaction as appropriate.
|
||||
|
||||
@@ -17,35 +17,24 @@ Use `search-docs` for detailed Livewire 4 patterns and documentation.
|
||||
### Creating Components
|
||||
|
||||
```bash
|
||||
|
||||
# Single-file component (SFC - default in v4)
|
||||
|
||||
# Creates: resources/views/components/⚡create-post.blade.php
|
||||
|
||||
php artisan make:livewire create-post
|
||||
|
||||
# Page component (SFC - Full Page in v4)
|
||||
|
||||
# Creates: resources/views/pages/⚡create-post.blade.php
|
||||
|
||||
php artisan make:livewire pages::create-post
|
||||
|
||||
# Multi-file component (MFC)
|
||||
|
||||
# Creates: resources/views/components/⚡create-post/create-post.php
|
||||
|
||||
# resources/views/components/⚡create-post/create-post.blade.php
|
||||
|
||||
php artisan make:livewire create-post --mfc
|
||||
|
||||
# Class-based component (v3 style)
|
||||
|
||||
# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php
|
||||
|
||||
php artisan make:livewire create-post --class
|
||||
|
||||
# With namespace
|
||||
|
||||
php artisan make:livewire Posts/CreatePost
|
||||
```
|
||||
|
||||
@@ -136,7 +125,7 @@ These things changed in Livewire 4, but may not have been updated in this applic
|
||||
|
||||
- Always use `wire:key` in loops
|
||||
- Use `wire:loading` for loading states
|
||||
- Use `wire:model.live` for instant updates (default is debounced)
|
||||
- Use `wire:model.live` for live updates; `wire:model` is deferred by default
|
||||
- Validate and authorize in actions (treat like HTTP requests)
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
---
|
||||
name: livewire-material-development
|
||||
description: Build Laravel and Livewire views with Livewire Material's Material 3 Expressive Blade components — props, slots, colour roles, type, shape, motion, theming, toasts, the design guard, and the Livewire traps each component handles.
|
||||
---
|
||||
|
||||
# Livewire Material Development
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Use this skill when writing or changing any Blade view, Livewire component view or layout in an application that requires `nonameweb/livewire-material`, and when styling, theming or testing such views.
|
||||
|
||||
## Setup
|
||||
|
||||
Composer packages must be installed before the Vite build (in Dockerfiles and CI alike), because the application's build imports from `vendor/`:
|
||||
|
||||
```css
|
||||
/* resources/css/app.css */
|
||||
@import 'tailwindcss';
|
||||
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
|
||||
@import './material-scheme.css';
|
||||
@source '../../vendor/nonameweb/livewire-material/resources/views';
|
||||
@source '../../vendor/nonameweb/livewire-material/src';
|
||||
```
|
||||
|
||||
```js
|
||||
// resources/js/app.js
|
||||
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||||
```
|
||||
|
||||
Every layout puts the theme script in `<head>`, before `@vite`:
|
||||
|
||||
```blade
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
```
|
||||
|
||||
## Colour scheme
|
||||
|
||||
The scheme is generated, never hand-edited. Regenerate it with the seed and variant recorded at the top of `resources/css/material-scheme.css`:
|
||||
|
||||
```bash
|
||||
php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||||
```
|
||||
|
||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
|
||||
|
||||
### Colour profiles
|
||||
|
||||
An installation that switches between several schemes lists them in `config/livewire-material.php` and runs the command without a seed, which generates every profile into the same stylesheet, keyed by `<html data-scheme>`:
|
||||
|
||||
```php
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
],
|
||||
'profile' => 'indigo', // the default; else the first
|
||||
```
|
||||
|
||||
```bash
|
||||
php artisan material:scheme
|
||||
```
|
||||
|
||||
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
|
||||
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||
```
|
||||
|
||||
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`.
|
||||
- Choose with `<x-scheme-picker wire:model="colorProfile" />` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on `<html>`.
|
||||
|
||||
## Tokens
|
||||
|
||||
Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing.
|
||||
|
||||
- Colour roles (`bg-*`, `text-*`, `border-*`, …): `primary`, `on-primary`, `primary-container`, `on-primary-container`, `inverse-primary`, `primary-fixed`, `primary-fixed-dim`, `on-primary-fixed`, `on-primary-fixed-variant`; the same for `secondary` and `tertiary`; `error`, `on-error`, `error-container`, `on-error-container`; `success`, `warning` and `info` with their `on-`, `-container` and `on-…-container`; `inverse-error|success|warning|info`; `surface`, `surface-dim`, `surface-bright`, `surface-container-lowest|low||high|highest`, `on-surface`, `on-surface-variant`, `inverse-surface`, `inverse-on-surface`, `outline`, `outline-variant`, `scrim`, `shadow`; plus `white` and `black`.
|
||||
- Ink and lines by meaning: `text-body` (body copy), `text-meta` (metadata), `text-quiet` (decoration only), `border-structure`, `border-chrome`, `border-divider` / `divide-divider`.
|
||||
- Type: `type-{display|headline|title|body|label}-{lg|md|sm}` and `type-emphasized-…`. Never assemble `text-*`, `leading-*` and `tracking-*` by hand. The font is Google Sans Flex (`font-sans`).
|
||||
- Shape: `rounded-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full}`.
|
||||
- Elevation: `shadow-elevation-{1…5}` — for what floats over content, not for panels (a panel separates by its container tone).
|
||||
- Motion: `ease-spatial-{fast|default|slow}` (position, size, shape; springs that overshoot) and `ease-effects-{fast|default|slow}` (colour, opacity). Always pair an easing with its duration: `duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast`. Reduced motion zeroes the durations.
|
||||
- States: `state-layer` (M3's hover/focus/press overlay; makes the element `relative` and `isolate`), `focus-ring` (keyboard focus indicator), `link` (a link in running text).
|
||||
- `dark:` follows the page's theme (`data-theme`), not the operating system.
|
||||
- `x-figure` on an element holding one number counts it up on first appearance and on change.
|
||||
|
||||
## Theme
|
||||
|
||||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
|
||||
|
||||
## Toasts
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||
|
||||
class Settings extends Component
|
||||
{
|
||||
use Toasts;
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
// …
|
||||
$this->success('Settings saved'); // also warning(), error(), info()
|
||||
$this->info('Link copied', timeout: 6000);
|
||||
$this->success('Share created', redirectTo: route('shares.show', $share));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The methods are protected. They dispatch a `toast` browser event (`assertDispatched('toast', type: 'success', title: 'Settings saved')` in tests).
|
||||
|
||||
## Error pages
|
||||
|
||||
Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's own 401 and 402 — render in M3 without setup. The provider appends the package's error views to `view.paths` after the application's, so a file in `resources/views/errors/` always wins.
|
||||
|
||||
- The pages load `config('livewire-material.showcase.vite')` and `<x-theme-script />`, so they use the app's scheme, font and theme. While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from `resources/css/material-scheme.json`.
|
||||
- `abort(403, 'Only the owner can open this share.')` and `abort(503, '…')` show the message as the sentence. Every other string goes through `__()`; translate them in `lang/{locale}.json`.
|
||||
- To change wording or design, run `php artisan vendor:publish --tag=livewire-material-errors`, which copies the layout and pages to `resources/views/errors`. A page extends `errors::minimal` and sets `title`, `code`, `headline`, `message`, `shape` (an `<x-shape>` name) and optionally `actions`:
|
||||
|
||||
```blade
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Payment Required'))
|
||||
@section('code', '402')
|
||||
@section('shape', 'cookie-4')
|
||||
@section('headline', __('Your plan has ended'))
|
||||
@section('message', __('Choose a plan to keep using the app.'))
|
||||
|
||||
@section('actions')
|
||||
<x-button :link="route('billing')" :label="__('Choose a plan')" variant="filled" size="md" no-wire-navigate />
|
||||
@endsection
|
||||
```
|
||||
|
||||
- Maintenance mode: `php artisan down --render="errors::503"`.
|
||||
- The showcase previews each page at `/material/errors/{code}`.
|
||||
|
||||
## Mail
|
||||
|
||||
Markdown mail (notifications and `markdown:` mailables) wears M3 once the application selects the theme:
|
||||
|
||||
```dotenv
|
||||
MAIL_MARKDOWN_THEME=livewire-material::mail.theme
|
||||
```
|
||||
|
||||
or per mail: `(new MailMessage)->theme('livewire-material::mail.theme')`, or `public $theme = 'livewire-material::mail.theme';` on a mailable.
|
||||
|
||||
- Colours are the light scheme from `resources/css/material-scheme.json` (`livewire-material.scheme`), inlined as hexes; regenerate the scheme and mail follows. Without the file, the package's default scheme applies.
|
||||
- Write the body as Markdown; the theme styles the bare tags (`#` headings, prose, lists, tables) with M3's typescale. `<x-mail::button :url="…">` is a filled pill in `primary`; `color` also takes `secondary`, `tertiary`, `error`, `success`, `warning` and `info`. `<x-mail::panel>` is a tinted container.
|
||||
- There is no dark mail. Never put `@media` rules, CSS variables or `color-mix()` in mail CSS: the inliner strips media queries and mail clients resolve no variables.
|
||||
- The package's mail header (the app name, or a logo) and message (with a replaceable footer) are opt-in: set `MATERIAL_MAIL_COMPONENTS=true`, or `php artisan vendor:publish --tag=livewire-material-mail` to copy them into `resources/views/vendor/mail`. For a logo set `livewire-material.mail.logo` to `['src' => 'https://example.com/logo.png', 'width' => 160, 'height' => 40]`: an absolute URL, with the image at twice those dimensions.
|
||||
- With those components, a mail can replace the footer:
|
||||
|
||||
```blade
|
||||
<x-mail::message>
|
||||
Your export is ready.
|
||||
|
||||
<x-slot:footer>
|
||||
© {{ date('Y') }} {{ config('app.name') }} · [Unsubscribe]({{ $unsubscribeUrl }})
|
||||
</x-slot:footer>
|
||||
</x-mail::message>
|
||||
```
|
||||
|
||||
- The showcase renders a sample mail at `/material/mail`.
|
||||
|
||||
## Components
|
||||
|
||||
### `<x-icon>`
|
||||
|
||||
A Material Symbol (Rounded, weight 400, grade 0, 24px), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws.
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `name` | required | `calendar_month`, `cloud_upload`, `content_copy` |
|
||||
| `filled` | `false` | the filled symbol — M3 uses it for active or selected |
|
||||
| `label` | `null` | names the icon for screen readers when it carries the meaning alone; otherwise it is `aria-hidden` |
|
||||
|
||||
24px (`size-6`) unless a `size-*`, `w-*` or `h-*` class is passed. Colour follows the text: `<x-icon name="lock" class="size-5 text-on-surface-variant" />`.
|
||||
|
||||
### `<x-shape>`
|
||||
|
||||
One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, sized by its caller: `<x-shape name="cookie-9" class="size-40 text-secondary-container" />`. Names: `circle`, `square`, `slanted`, `arch`, `fan`, `arrow`, `semi-circle`, `oval`, `pill`, `triangle`, `diamond`, `clam-shell`, `pentagon`, `gem`, `very-sunny`, `sunny`, `cookie-4`, `cookie-6`, `cookie-7`, `cookie-9`, `cookie-12`, `ghostish`, `clover-4`, `clover-8`, `burst`, `soft-burst`, `boom`, `soft-boom`, `flower`, `puffy`, `puffy-diamond`, `pixel-circle`, `pixel-triangle`, `bun`, `heart`.
|
||||
|
||||
### `<x-theme-script>`
|
||||
|
||||
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`.
|
||||
|
||||
### `<x-button>`
|
||||
|
||||
Label button, icon button, toggle and responsive FAB in one component.
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `label` / slot | | the words; without them and with an `icon` it is an icon button |
|
||||
| `variant` | `text` | `filled`, `tonal`, `outlined`, `elevated`, `text` |
|
||||
| `color` (alias `tone`) | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` |
|
||||
| `primary`, `danger`, `caution` | | shorthands: filled primary, filled error, filled warning |
|
||||
| `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px |
|
||||
| `shape` | `round` | or `square`; both square off further while pressed |
|
||||
| `icon`, `icon-right` | | Material Symbol names |
|
||||
| `width` | `default` | icon buttons only: `narrow`, `default`, `wide` |
|
||||
| `selected` | `null` | `true`/`false` makes it a toggle (`aria-pressed`, selected colours and shape) |
|
||||
| `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external |
|
||||
| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action |
|
||||
| `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name |
|
||||
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `lg`; `fab` is an extended FAB below `sm`, a filled button above |
|
||||
|
||||
```blade
|
||||
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
|
||||
<x-button icon="delete" tooltip="Delete share" wire:click="delete({{ $share->id }})" />
|
||||
<x-button icon="favorite" aria-label="Keep" variant="tonal" :selected="$kept" wire:click="toggleKeep" />
|
||||
```
|
||||
|
||||
### `<x-tooltip>`
|
||||
|
||||
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus; `aria-hidden`, so the trigger still needs its own accessible name. Buttons and FABs take a `tooltip` prop instead.
|
||||
|
||||
### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>`
|
||||
|
||||
```blade
|
||||
<x-menu label="Share actions" position="bottom-end">
|
||||
<x-slot:trigger>
|
||||
<x-button icon="more_vert" tooltip="More" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-group label="Sort by">
|
||||
<x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="$set('sort', 'newest')" keep-open />
|
||||
</x-menu-group>
|
||||
<x-menu-separator />
|
||||
<x-menu-item label="Settings" icon="settings" link="{{ route('settings') }}" />
|
||||
<x-menu-item label="Delete" icon="delete" wire:click="delete" description="Recipients lose access" shortcut="⌘⌫" />
|
||||
</x-menu>
|
||||
```
|
||||
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
||||
|
||||
### `<x-button-group>`
|
||||
|
||||
A row of `<x-button>`s: `<x-button-group label="View" size="md">…</x-button-group>`. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside.
|
||||
|
||||
### `<x-group>`
|
||||
|
||||
A choice between a few options as a connected button group of native radios (checkboxes with `multiple`):
|
||||
|
||||
```blade
|
||||
<x-group label="Expires after" wire:model.live="expiry" :options="[
|
||||
['id' => '1h', 'name' => '1 hour'],
|
||||
['id' => '1d', 'name' => '1 day', 'icon' => 'today'],
|
||||
['id' => '7d', 'name' => '7 days', 'disabled' => true],
|
||||
]" hint="Recipients lose access after that" />
|
||||
```
|
||||
|
||||
Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
||||
|
||||
### `<x-split-button>`
|
||||
|
||||
```blade
|
||||
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options">
|
||||
<x-menu-item label="Download files one by one" wire:click="downloadEach" />
|
||||
</x-split-button>
|
||||
```
|
||||
|
||||
Attributes go to the leading button; the slot is the menu. `variant` (`filled` default, `tonal`, `outlined`, `elevated`), `color`, `size`, `disabled`, `spinner`, `menu-label`, `position`.
|
||||
|
||||
### `<x-fab>`
|
||||
|
||||
`<x-fab icon="add" tooltip="New share" />` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4">`). `link`, `external`, `disabled`, `type`.
|
||||
|
||||
### `<x-fab-menu>`, `<x-fab-menu-item>`
|
||||
|
||||
```blade
|
||||
<div class="fixed end-4 bottom-4">
|
||||
<x-fab-menu label="New">
|
||||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
|
||||
<x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" />
|
||||
</x-fab-menu>
|
||||
</div>
|
||||
```
|
||||
|
||||
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as `<x-menu>`.
|
||||
|
||||
### `<x-loading>`
|
||||
|
||||
M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and `primary` unless sized or coloured by class; `contained` sets it on a primary-container circle. A `progressbar` named by `label` ("Loading"); `:label="false"` makes it decorative. It rests under reduced motion.
|
||||
|
||||
```blade
|
||||
<x-loading />
|
||||
<x-loading contained class="size-8" label="Uploading" />
|
||||
<div wire:loading.flex wire:target="upload"><x-loading /></div>
|
||||
```
|
||||
|
||||
`<x-button spinner>` shows a decorative one in place of its icon.
|
||||
|
||||
### `<x-toast>`
|
||||
|
||||
The snackbar host. Once per layout, near the end of `<body>`: `<x-toast />` (`position="bottom-start"` to leave the centre free). It is `@persist`ed across `wire:navigate` and shows, one at a time, every toast from the `Toasts` concern (see Toasts above) or from JavaScript:
|
||||
|
||||
```js
|
||||
materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } })
|
||||
```
|
||||
|
||||
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
|
||||
|
||||
### `<x-progress>`
|
||||
|
||||
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `value` | `null` | 0 to `max`, clamped; `null` is indeterminate |
|
||||
| `max` | `100` | |
|
||||
| `bind` | `null` | an Alpine expression it follows in the browser; `null`/`undefined` is indeterminate |
|
||||
| `circular` | `false` | circular instead of linear |
|
||||
| `wavy` | `false` | Expressive's wave (flat below 10% and from 95%) |
|
||||
| `thick` | `false` | 8px track and indicator instead of 4px |
|
||||
| `color` | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info`; the track is the colour's container (secondary-container for primary) |
|
||||
| `label` | `"Progress"` | names the `progressbar`; `:label="false"` makes it decorative |
|
||||
|
||||
```blade
|
||||
<x-progress :value="$share->uploaded" :max="$share->size" label="Uploading" />
|
||||
<x-progress circular wavy label="Preparing the download" />
|
||||
|
||||
<div x-data="{ progress: null }" x-on:livewire-upload-progress="progress = $event.detail.progress" x-on:livewire-upload-finish="progress = null">
|
||||
<input type="file" wire:model="file">
|
||||
<div x-show="progress !== null"><x-progress bind="progress" wavy label="Uploading" /></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
A value the server changes animates after a morph (the SVG is `wire:ignore`; only the root's attributes change). Under reduced motion values jump, the wave stands still and an indeterminate indicator holds one frame. A `w-*` class narrows a linear one; never pass a display or position class.
|
||||
|
||||
### `<x-badge>`
|
||||
|
||||
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
||||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
|
||||
|
||||
### `<x-alert>`
|
||||
|
||||
A notice in the page, in the state's container colour with its icon:
|
||||
|
||||
```blade
|
||||
<x-alert title="Storage almost full" description="3.8 GB of 4 GB used." color="warning" />
|
||||
<x-alert color="error" dismissible>
|
||||
The upload failed.
|
||||
<x-slot:actions><x-button label="Try again" wire:click="retry" /></x-slot:actions>
|
||||
</x-alert>
|
||||
```
|
||||
|
||||
`color` (alias `tone`): `info` (default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, `neutral`. `icon` overrides the state icon; `:icon="false"` removes it. Errors and warnings are `role="alert"`, the rest `role="status"`.
|
||||
|
||||
### `<x-rich-tooltip>`
|
||||
|
||||
A few lines of context around a trigger, with an optional `title` and `actions` slot:
|
||||
|
||||
```blade
|
||||
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time.">
|
||||
<x-button icon="help" aria-label="About expiry" />
|
||||
</x-rich-tooltip>
|
||||
```
|
||||
|
||||
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). `side`: `bottom` (default), `top`, `left`, `right`.
|
||||
|
||||
### `<x-stat>`
|
||||
|
||||
`<x-stat title="Shares" value="1,204" icon="link" description="12 this week" />` — a figure on a surface-container panel; the value counts up on first appearance and when it changes. The slot goes under the description (e.g. a quota's `<x-progress>`). Do not pass a `bg-*` class; wrap it.
|
||||
|
||||
### `<x-empty-state>`
|
||||
|
||||
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
|
||||
|
||||
### `<x-card>`
|
||||
|
||||
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
|
||||
|
||||
A card or list item that opens something is a **row**: `data-list-row` on it and `data-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link.
|
||||
|
||||
```blade
|
||||
<x-card variant="outlined" data-list-row wire:key="share-{{ $share->id }}">
|
||||
<a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate class="type-title-md">{{ $share->name }}</a>
|
||||
<x-slot:actions><x-button label="Copy link" wire:click="copy({{ $share->id }})" /></x-slot:actions>
|
||||
</x-card>
|
||||
```
|
||||
|
||||
### `<x-list>`, `<x-list-item>`
|
||||
|
||||
`<x-list>`: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). `<x-list-item>`: `title` (or slot), `overline`, `description`, leading `icon` / `avatar` (image URL or initials) / `image` / `leading` slot, trailing `trailing` text / `icon-right` / `end` slot, `link` (the whole item becomes a row that opens it), `selected`, `disabled`. One-, two- and three-line heights follow from the content.
|
||||
|
||||
```blade
|
||||
<x-list segmented label="Files">
|
||||
@foreach ($files as $file)
|
||||
<x-list-item :title="$file->name" :description="$file->size" icon="description" wire:key="file-{{ $file->id }}">
|
||||
<x-slot:end><x-button icon="download" tooltip="Download" wire:click="download({{ $file->id }})" /></x-slot:end>
|
||||
</x-list-item>
|
||||
@endforeach
|
||||
</x-list>
|
||||
```
|
||||
|
||||
### `<x-divider>`
|
||||
|
||||
`<x-divider />` — outline-variant line; `vertical`, `inset` (16px start), `middle`, `decorative` (hidden from assistive tech).
|
||||
|
||||
### `<x-collapse>`
|
||||
|
||||
A disclosure on native `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph.
|
||||
|
||||
### `<x-modal>`
|
||||
|
||||
An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; closing (Escape, scrim, `close()`) writes back `false` or `null`. Without `wire:model` it uses `open` from the surrounding Alpine scope.
|
||||
|
||||
```blade
|
||||
<x-modal wire:model="deletingId" title="Delete this share?" subtitle="Recipients lose access at once." icon="delete">
|
||||
<x-slot:actions>
|
||||
<x-button label="Cancel" x-on:click="close()" />
|
||||
<x-button label="Delete" danger wire:click="delete" />
|
||||
</x-slot:actions>
|
||||
</x-modal>
|
||||
```
|
||||
|
||||
Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent` (no Escape or scrim), `fullscreen` (whole screen below `sm`, for forms), `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead.
|
||||
|
||||
### `<x-drawer>`
|
||||
|
||||
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
|
||||
|
||||
### `<x-bottom-sheet>`
|
||||
|
||||
An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`90dvh`), `actions` slot.
|
||||
|
||||
### `<x-carousel>`, `<x-carousel-item>`
|
||||
|
||||
```blade
|
||||
<x-carousel label="Recent uploads" item-width="220">
|
||||
@foreach ($photos as $photo)
|
||||
<x-carousel-item :label="$photo->title" wire:key="photo-{{ $photo->id }}">
|
||||
<img src="{{ $photo->url }}" alt="{{ $photo->alt }}" />
|
||||
</x-carousel-item>
|
||||
@endforeach
|
||||
</x-carousel>
|
||||
```
|
||||
|
||||
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, 0), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A focusable `region` of `slide` groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
|
||||
|
||||
### `<x-chip>`
|
||||
|
||||
One component for M3's four chips, picked by `type`:
|
||||
|
||||
| `type` | What it is | Element |
|
||||
|---|---|---|
|
||||
| `assist` (default) | an action | `<button>`, or `<a>` with `link` |
|
||||
| `filter` | a toggle | a native checkbox under the chip with `wire:model`, `x-model` or `name`; otherwise a `<button aria-pressed>` whose `selected` you own |
|
||||
| `input` | something a person entered | its own button only with `wire:click`, `x-on:click`, `link` or `selected`; a remove button with `removable` |
|
||||
| `suggestion` | a suggested reply or query | `<button>` |
|
||||
|
||||
Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `disabled`, `link`, `external`, `no-wire-navigate`, `selected` (filter and input), `name` / `value` (a filter checkbox; an input chip's hidden input, `value` defaulting to the label), `avatar` (input: image URL or initials), `removable`, `remove` (input: an Alpine expression), `tooltip`. On a filter checkbox and an input chip, `class`, `style` and `wire:key` stay on the chip and every other attribute goes to the control inside.
|
||||
|
||||
```blade
|
||||
<x-chip label="Add to calendar" icon="event" wire:click="addToCalendar" />
|
||||
|
||||
<x-chip-set label="File types" hint="Show only these" error-field="kinds">
|
||||
@foreach ($kindOptions as $kind => $name)
|
||||
<x-chip type="filter" :label="$name" :value="$kind" wire:model.live="kinds" wire:key="kind-{{ $kind }}" />
|
||||
@endforeach
|
||||
</x-chip-set>
|
||||
|
||||
<x-chip type="filter" label="Starred" icon="star" :selected="$starredOnly" wire:click="$toggle('starredOnly')" />
|
||||
|
||||
@foreach ($recipients as $recipient)
|
||||
<x-chip type="input" :label="$recipient->email" :avatar="$recipient->initials" removable wire:remove="removeRecipient({{ $recipient->id }})" wire:key="recipient-{{ $recipient->id }}" />
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- A multi-select set binds `wire:model` on every chip, each with its own `value`, to an array property; a boolean property needs no `value`. The chips render checked as the property already says.
|
||||
- A removable input chip removes through `wire:remove` (it becomes the remove button's `wire:click`), `remove` (Alpine), or, with neither, takes itself off the page. Backspace or Delete on a focused chip removes it and moves focus to the previous or next chip; the remove button is named "Remove <label>". Give each one a `wire:key`.
|
||||
|
||||
### `<x-chip-set>`
|
||||
|
||||
A row of chips 8px apart that wraps, as `role="group"`: `label` (shown, and names the group; otherwise pass `aria-label`), `hint`, `error-field` (a validation message for that property or its items replaces the hint), `scroll` (one line that scrolls sideways, fading the edge it can still scroll towards).
|
||||
|
||||
### `<x-form>`
|
||||
|
||||
A one-column grid of fields with an `actions` slot at the foot (the slot takes its own `class`); `separator` draws a divider above the actions.
|
||||
|
||||
```blade
|
||||
<x-form wire:submit="save">
|
||||
<x-input label="Share name" wire:model="name" required />
|
||||
<x-select label="Expires after" wire:model="hours" :options="$expiryOptions" />
|
||||
<x-slot:actions>
|
||||
<x-button label="Cancel" wire:click="cancel" />
|
||||
<x-button label="Create share" variant="filled" type="submit" spinner="save" />
|
||||
</x-slot:actions>
|
||||
</x-form>
|
||||
```
|
||||
|
||||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
||||
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
|
||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||||
- `<x-password>`: a reveal button; `icon`, `size`.
|
||||
- `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one.
|
||||
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`.
|
||||
- `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself.
|
||||
- `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `class="field-control"`; only for controls the package does not have.
|
||||
|
||||
### `<x-checkbox>`, `<x-radio>`, `<x-toggle>`
|
||||
|
||||
M3 selection controls on native inputs; the whole row is the label.
|
||||
|
||||
- `<x-checkbox label hint right indeterminate />` — `indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render).
|
||||
- `<x-radio label wire:model :options inline />` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group.
|
||||
- `<x-toggle label hint right icons />` — M3 switch (`role="switch"`); `icons` puts a check and a cross on the handle, `icons="selected"` only the check. Without `label`, pass `aria-label`.
|
||||
|
||||
```blade
|
||||
<x-checkbox label="All files" :checked="count($selected) === $files->count()" :indeterminate="$selected && count($selected) < $files->count()" wire:click="toggleAll" />
|
||||
<x-toggle label="Notify me on download" wire:model.live="notify" right />
|
||||
```
|
||||
|
||||
### `<x-slider>`
|
||||
|
||||
M3 Expressive's slider on native `<input type="range">`s (one per handle), so the arrow keys, Home, End, forms and screen readers work as on a plain range; PageUp and PageDown move a tenth of the steps (1 to 10). A press anywhere on the slider moves the nearest handle there. Without JavaScript the native range shows, and posts.
|
||||
|
||||
```blade
|
||||
<x-slider label="Volume" wire:model.live="volume" hint="Applies at once" />
|
||||
<x-slider label="Price" wire:model="price" range :max="500" :step="10" ticks />
|
||||
<x-slider label="Balance" x-model="balance" name="balance" :min="-50" :max="50" centered />
|
||||
<x-slider label="Brightness" name="brightness" value="60" size="lg" icon="light_mode" />
|
||||
```
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `label`, `hint` | | the label above names the input (with `range`, the group); a validation error for the bound property or `name` replaces the hint |
|
||||
| `value`, `min`, `max`, `step` | `null`, `0`, `100`, `1` | as on a range input; `step="any"` is continuous. With `wire:model` the property's value is drawn |
|
||||
| `name` | the `wire:model` property | with `range` it posts `name[]` twice, from first |
|
||||
| `range` | `false` | two handles that never cross; binds an array `[from, to]` (`wire:model="price"` binds `price.0` and `price.1`, `x-model="price"` binds `price[0]` and `price[1]`) |
|
||||
| `centered` | `false` | fills from the middle of the track, for values that go below zero |
|
||||
| `size` | `xs` | track `xs` 16px, `sm` 24px, `md` 40px, `lg` 56px, `xl` 96px |
|
||||
| `icon` | `null` | a Material Symbol inside the track, `md` and up, standard sliders only |
|
||||
| `ticks` | `false` | a mark per step (up to 200, hidden while closer than 8px); the handle sits on the marks |
|
||||
| `value-label` | `drag` | `drag` (while pressed, dragged or keyboard-focused), `always`, `never` |
|
||||
| `color` | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` |
|
||||
| `disabled` | `false` | |
|
||||
|
||||
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
|
||||
|
||||
### `<x-datepicker>`
|
||||
|
||||
M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire).
|
||||
|
||||
```blade
|
||||
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
|
||||
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
|
||||
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
|
||||
```
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog below `sm`; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
|
||||
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field |
|
||||
| `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside |
|
||||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
|
||||
| `value` | `null` | the initial value without `wire:model` |
|
||||
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
|
||||
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
|
||||
|
||||
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
|
||||
|
||||
### `<x-timepicker>`
|
||||
|
||||
M3's time picker in a modal dialog, opened from a read-only text field (a press, Enter, Space, ArrowDown or its clock icon). The dial picks the hour, then the minutes, by press or drag; a 24-hour clock puts 12–23 on the inner ring. The keyboard icon switches to two text fields. The arrow keys change the focused dial's value, Home and End go to the ends, Enter confirms; Escape, Cancel or the scrim close it unchanged and give focus back to the field. In a landscape window the dial lies on its side.
|
||||
|
||||
```blade
|
||||
<x-timepicker label="Starts at" wire:model="startsAt" />
|
||||
<x-timepicker label="Appointment" wire:model.live="appointmentAt" format="24" step="15" min="08:00" max="17:30" clearable />
|
||||
```
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `wire:model` / `x-model` | | the time as `H:i`, null until chosen; `H:i:s` (a `time` column) is read and written back as `H:i`; nothing is written until OK |
|
||||
| `format` | the locale's | `12` or `24`; otherwise the hour cycle of `locale` as `Intl.DateTimeFormat` reports it |
|
||||
| `locale` | app locale | the hour cycle, and how the field writes the time |
|
||||
| `step` | `1` | minutes between choices (a tap picks fives, or steps when five is not a multiple of the step) |
|
||||
| `min`, `max` | | `H:i`, inclusive; `min` later than `max` spans midnight. Outside values are greyed out and refused in the picker — validate on the server as well |
|
||||
| `clearable` | `false` | a button that empties the field |
|
||||
| `name` | | posts the value from a hidden input |
|
||||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's; `required`, `disabled` and `placeholder` reach its input |
|
||||
|
||||
Errors under the `wire:model` name replace the hint. The dialog is `wire:ignore`: a Livewire render leaves an open picker open with its draft. Never name a Livewire property `$slot`: it renders empty in the component's view.
|
||||
|
||||
### `<x-choices>`
|
||||
|
||||
Choosing from a list, with typed values (an array of integers stays integers). `options` (`id`, `name`, `disabled`; `option-value`, `option-label`), `label`, `hint`, `single`. Errors for the property and its items replace the hint.
|
||||
|
||||
- Default: filter chips, every option on screen — `single` for choice chips.
|
||||
- `searchable`: a text field that filters a menu as you type (single value; arrow keys, Enter, Escape); `icon`, `variant`, `placeholder`. Its list is a popover, so it is never clipped by a card.
|
||||
|
||||
```blade
|
||||
<x-choices label="Days you are free" wire:model.live="days" :options="$weekdays" />
|
||||
<x-choices label="Time zone" wire:model="timezone" :options="$timezones" searchable icon="public" />
|
||||
```
|
||||
|
||||
Bind with `wire:model` (entangled) or, without Livewire, `x-model`. The options are baked into its Alpine state: when they change on the server, give it a `wire:key` that changes with them.
|
||||
|
||||
### `<x-search>`
|
||||
|
||||
M3 search bar that opens into a search view: docked under the bar from `sm`, full screen with a back arrow below (`docked` keeps it docked). Bind the input like any other and render the results in the slot; `empty` is shown when the slot renders nothing. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`; `trailing` slot (avatar, icon buttons).
|
||||
|
||||
```blade
|
||||
<x-search wire:model.live.debounce.300ms="query" placeholder="Search shares">
|
||||
@foreach ($this->results as $share)
|
||||
<x-list-item :title="$share->name" :description="$share->size" link="{{ route('shares.show', $share) }}" wire:key="result-{{ $share->id }}" />
|
||||
@endforeach
|
||||
<x-slot:empty>No shares match.</x-slot:empty>
|
||||
</x-search>
|
||||
```
|
||||
|
||||
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it.
|
||||
|
||||
### `<x-app-shell>`
|
||||
|
||||
The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a collapsed rail that opens as a modal to `lg`, an expanded rail the visitor can collapse from `lg`, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
|
||||
|
||||
```blade
|
||||
<x-app-shell :destinations="[
|
||||
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares.index'), 'active' => request()->routeIs('shares.*'), 'badge' => $expiringCount],
|
||||
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
|
||||
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
|
||||
]">
|
||||
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
||||
<x-slot:rail-header>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
|
||||
</x-slot:rail-header>
|
||||
<x-slot:rail-footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
|
||||
</x-slot:rail-footer>
|
||||
<x-slot:top>
|
||||
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
|
||||
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
|
||||
</x-slot:top>
|
||||
|
||||
{{ $slot }}
|
||||
</x-app-shell>
|
||||
```
|
||||
|
||||
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
||||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
|
||||
- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it.
|
||||
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
|
||||
|
||||
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
|
||||
|
||||
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-app-shell>` does):
|
||||
|
||||
```blade
|
||||
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
```
|
||||
|
||||
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
|
||||
|
||||
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
|
||||
|
||||
M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (a 56px full-width pill, icon beside label, count at the end).
|
||||
|
||||
```blade
|
||||
<div class="flex min-h-dvh">
|
||||
<x-navigation-rail mode="collapsible">
|
||||
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
|
||||
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
|
||||
<x-navigation-rail-section label="Admin">
|
||||
<x-navigation-rail-item label="Users" icon="group" link="{{ route('users') }}" />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
|
||||
<main class="min-w-0 flex-1">…</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (`<x-app-shell>`'s: hidden and opened as a modal below `sm`, collapsed and opened as a modal to `lg`, collapsible from `lg`).
|
||||
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
|
||||
- Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
|
||||
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
|
||||
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
|
||||
|
||||
### `<x-app-bar>`
|
||||
|
||||
M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away), turning surface-container once content scrolls under it. `variant`: `small` (default), `center`, `medium` and `large` (a big title that collapses into the row as the page scrolls — CSS sticky, no layout shift), `search` (put an `<x-search>` in the slot). Props: `title`, `subtitle`, `heading` (`h1` default). Slots: `navigation` (leading icon button), `actions` (trailing icon buttons, avatar).
|
||||
|
||||
```blade
|
||||
<x-app-bar variant="medium" title="Recipients" subtitle="3 people">
|
||||
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" :link="route('shares.index')" /></x-slot:navigation>
|
||||
<x-slot:actions><x-button icon="person_add" tooltip="Add recipient" wire:click="add" /></x-slot:actions>
|
||||
</x-app-bar>
|
||||
```
|
||||
|
||||
A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/`overflow-auto` (`overflow-x-clip` is fine).
|
||||
|
||||
### `<x-toolbar>`
|
||||
|
||||
M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `variant`: `floating` (default pill at elevation 3; `vibrant`, `vertical`) or `docked` (full-width surface-container bar). `place`: `bottom` or `end` to fix it over the page; `fab` slot sets a FAB beside a floating toolbar; `label` names it.
|
||||
|
||||
```blade
|
||||
<x-toolbar label="Selection" place="bottom" vibrant>
|
||||
<x-button icon="download" tooltip="Download" wire:click="download" />
|
||||
<x-button icon="delete" tooltip="Delete" wire:click="delete" />
|
||||
<x-slot:fab><x-fab icon="add" tooltip="New share" /></x-slot:fab>
|
||||
</x-toolbar>
|
||||
```
|
||||
|
||||
### `<x-tabs>`, `<x-tab>`
|
||||
|
||||
M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skipped, the indicator moves in a view transition). `tabs`: `['name', 'label', 'icon', 'badge', 'disabled']`; panels are `<x-tab name>` in the slot. Bind with `wire:model` (entangled), or `selected` / `x-model` without Livewire. `variant` `primary` (default) or `secondary`; `stacked` (icon over label), `scrollable`. Give two identical tab sets on one page distinct `id`s.
|
||||
|
||||
```blade
|
||||
<x-tabs wire:model.live="tab" :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People', 'badge' => $pending]]">
|
||||
<x-tab name="files">…</x-tab>
|
||||
<x-tab name="people">…</x-tab>
|
||||
</x-tabs>
|
||||
```
|
||||
|
||||
### `<x-section-nav>`
|
||||
|
||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the request's. `label`, `no-wire-navigate`.
|
||||
|
||||
### `<x-account-menu>`
|
||||
|
||||
An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; default the name's initials), items in the slot, a theme item (`:theme="false"` to drop it), and a `footer` slot for signing out. `label`, `position`.
|
||||
|
||||
```blade
|
||||
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email">
|
||||
<x-menu-item label="Settings" icon="settings" :link="route('settings')" />
|
||||
<x-slot:footer>
|
||||
<form method="POST" action="{{ route('logout') }}">@csrf<x-menu-item label="Sign out" icon="logout" type="submit" /></form>
|
||||
</x-slot:footer>
|
||||
</x-account-menu>
|
||||
```
|
||||
|
||||
### `<x-theme-toggle>`
|
||||
|
||||
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
|
||||
|
||||
### `<x-scheme-picker>`
|
||||
|
||||
A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour — over native radios. `wire:model` or `x-model` (with `name`) binds the chosen name; choosing previews it on the page at once; storing it is the application's. `label`, `hint`, `name`, `profiles` (default `Scheme::profiles()`). A validation error for the bound property replaces the hint. Without profiles it renders nothing.
|
||||
|
||||
```blade
|
||||
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
|
||||
```
|
||||
|
||||
### `<x-table>`, `<x-sort-header>`
|
||||
|
||||
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
|
||||
|
||||
`<x-sort-header column="size" :sort-by="$sortBy">Size</x-sort-header>` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`.
|
||||
|
||||
```blade
|
||||
<div class="overflow-x-auto">
|
||||
<x-table>
|
||||
<thead><tr><x-sort-header column="name" :sort-by="$sortBy">Name</x-sort-header><th class="text-end">Size</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($shares as $share)
|
||||
<tr data-list-row wire:key="share-{{ $share->id }}">
|
||||
<td><a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate>{{ $share->name }}</a></td>
|
||||
<td class="text-end tabular-nums">{{ $share->size }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</x-table>
|
||||
</div>
|
||||
{{ $shares->links() }}
|
||||
```
|
||||
|
||||
Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor) is drawn in M3 — current page in secondary-container, "Page 2 of 7" on a phone. Turn off with `config('livewire-material.pagination')` = `false`; published `vendor/pagination` or `vendor/livewire` views still win.
|
||||
|
||||
## Testing the design
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
|
||||
it('uses only what compiles', function () {
|
||||
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
|
||||
->forbidColours(['tertiary']) // roles this application's rules leave out
|
||||
->violations())->toBe([]);
|
||||
});
|
||||
```
|
||||
|
||||
It fails on maryUI tags, daisyUI classes, colours the theme does not declare, unknown symbol names and Blade directives written inside a component tag (where they do not compile), with `path:line` for each.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works.
|
||||
- Write class names out whole. Tailwind cannot compile `'text-'.$tone` or `type-{{ $size }}`, and the design guard cannot read them.
|
||||
- The showcase at `/material` (local only, `MATERIAL_SHOWCASE=true` to force it) renders every token and component.
|
||||
|
||||
## Livewire traps
|
||||
|
||||
- Blade directives do not compile inside a component tag's attributes: `<x-foo x-show="ok(@js($value))">` reaches the browser as literal text. On a component tag use `{{ }}` and `:prop` bindings, or put the Alpine on a plain element inside the slot.
|
||||
- Never pass `hidden`, a display utility or a position (`absolute`, `relative`) to a component: it is merged beside the component's own and whichever Tailwind emits last wins. Wrap the component in an element that carries it. A variant that only hides (`max-sm:hidden`) is safe.
|
||||
- `$attributes->wire('model')->value()` is `false`, not `null`, when there is no `wire:model`, and `filled(false)` is true. Normalise with `?: null`.
|
||||
- End every statement in a multi-line Alpine attribute with `;`: an inline `@if … @endif` inside it swallows the newline after it.
|
||||
@@ -1,166 +0,0 @@
|
||||
---
|
||||
name: pest-testing
|
||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Pest Testing 4
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Pest 4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Tests
|
||||
|
||||
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||
|
||||
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
||||
- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
||||
- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php`
|
||||
- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
||||
- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php`
|
||||
|
||||
### Test Organization
|
||||
|
||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||
- Browser tests: `tests/Browser/` directory.
|
||||
- Do NOT remove tests without approval - these are core application code.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||
|
||||
<!-- Basic Pest Test Example -->
|
||||
```php
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
|
||||
- Run all tests: `php artisan test --compact`.
|
||||
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
|
||||
## Assertions
|
||||
|
||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||
|
||||
<!-- Pest Response Assertion -->
|
||||
```php
|
||||
it('returns all', function () {
|
||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||
});
|
||||
```
|
||||
|
||||
| Use | Instead of |
|
||||
|-----|------------|
|
||||
| `assertSuccessful()` | `assertStatus(200)` |
|
||||
| `assertNotFound()` | `assertStatus(404)` |
|
||||
| `assertForbidden()` | `assertStatus(403)` |
|
||||
|
||||
## Mocking
|
||||
|
||||
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||
|
||||
## Datasets
|
||||
|
||||
Use datasets for repetitive tests (validation rules, etc.):
|
||||
|
||||
<!-- Pest Dataset Example -->
|
||||
```php
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
```
|
||||
|
||||
## Pest 4 Features
|
||||
|
||||
| Feature | Purpose |
|
||||
|---------|---------|
|
||||
| Browser Testing | Full integration tests in real browsers |
|
||||
| Smoke Testing | Validate multiple pages quickly |
|
||||
| Visual Regression | Compare screenshots for visual changes |
|
||||
| Test Sharding | Parallel CI runs |
|
||||
| Architecture Testing | Enforce code conventions |
|
||||
|
||||
### Browser Test Example
|
||||
|
||||
Browser tests run in real browsers for full integration testing:
|
||||
|
||||
- Browser tests live in `tests/Browser/`.
|
||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||
- Use `RefreshDatabase` for clean state per test.
|
||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging.
|
||||
|
||||
<!-- Pest Browser Test Example -->
|
||||
```php
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in');
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavaScriptErrors()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!');
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
```
|
||||
|
||||
### Smoke Testing
|
||||
|
||||
Quickly validate multiple pages have no JavaScript errors:
|
||||
|
||||
<!-- Pest Smoke Testing Example -->
|
||||
```php
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||
```
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
Capture and compare screenshots to detect visual changes.
|
||||
|
||||
### Test Sharding
|
||||
|
||||
Split tests across parallel processes for faster CI runs.
|
||||
|
||||
### Architecture Testing
|
||||
|
||||
Pest 4 includes architecture testing (from Pest 3):
|
||||
|
||||
<!-- Architecture Test Example -->
|
||||
```php
|
||||
arch('controllers')
|
||||
->expect('App\Http\Controllers')
|
||||
->toExtendNothing()
|
||||
->toHaveSuffix('Controller');
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||
- Forgetting datasets for repetitive validation tests
|
||||
- Deleting tests without approval
|
||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||
@@ -87,29 +87,6 @@ If existing pages and components support dark mode, new pages and components mus
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: testing-best-practices
|
||||
description: "Laravel test design and review. Use when selecting coverage, naming or structuring tests, choosing assertions or test data, isolating dependencies, testing HTTP or security boundaries, improving suite performance, or reviewing test value. Use framework guidance or search-docs for Pest and PHPUnit syntax."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Testing Best Practices
|
||||
|
||||
This skill provides rules for designing Laravel tests. Each rule file explains what to do and why. Use `search-docs` for Laravel and Pest API syntax.
|
||||
This project uses Pest. Follow the corresponding guidance in each rule.
|
||||
|
||||
## Consistency First
|
||||
|
||||
Read nearby tests before you choose syntax and organization.
|
||||
|
||||
A pattern repeated throughout the project is a convention, and project conventions take precedence over this skill. Follow them and give new tests the same structure.
|
||||
|
||||
These rules govern the tests you write now. An existing test that follows a project convention is not defective merely because it conflicts with this skill. Do not delete or rewrite it. If the convention has drawbacks, explain them and let the user decide.
|
||||
|
||||
## What to Test
|
||||
|
||||
Read this section before you write a test.
|
||||
|
||||
- Test observable behavior and application contracts. A test must pass after an implementation change if the behavior stays the same.
|
||||
- Cover every changed decision and each applicable high-value failure mode. A decision is a branch, a validation, a calculation, or an authorization.
|
||||
- Exercise declarations through behavior instead of repeating their text.
|
||||
- Leave framework behavior to framework tests. Testing project configuration is not testing the framework. A constrained relationship, cast, scope, or validation rule belongs to this project.
|
||||
- Keep every test that can detect a distinct defect. When two tests detect the same defect, trim the higher-layer test to one case and report the duplication. Do not delete an existing test.
|
||||
- Write a feature test first. Write a unit test only for logic that does not use the framework.
|
||||
- Write a browser test only for behavior in JavaScript that a feature test cannot reach. Put a browser test in `tests/Browser`, and call `assertNoJavaScriptErrors()` in it.
|
||||
- Judge an architecture test by the convention it protects, not by the rules above. An `arch()` test declares a rule for an entire directory, such as the parent class of every model, the classes that may use an enum, or the methods every factory declares. It intentionally checks declarations and fails when a new file breaks the convention.
|
||||
- Use the test tools that the project installs. Add a new test dependency, plugin, or browser only after the user asks for it.
|
||||
|
||||
## How to Apply
|
||||
|
||||
1. Read the code under test. Read the tests in the same directory. Identify every decision in the code.
|
||||
2. Select every applicable branch in the rule index. Read every selected rule file.
|
||||
3. Report each defect in the code before you write a test. Examples are a method with no body, a policy that no action calls, and a write action with no validation. Test the actual behavior. Report the defect to the user.
|
||||
4. Write the tests. Run the smallest set of tests that covers the change. The tests must pass.
|
||||
5. Check every applicable item in `rules/review.md` and every selected rule file. Resolve every mismatch before completion.
|
||||
|
||||
## Rule Index
|
||||
|
||||
Most changes need more than one rule file.
|
||||
|
||||
| Subject | Rule File |
|
||||
| --- | --- |
|
||||
| Test framework features that may already do the work | [`rules/finding-features.md`](rules/finding-features.md) |
|
||||
| File layout, test names, and groups | [`rules/naming.md`](rules/naming.md) |
|
||||
| Arrange-act-assert and choosing the correct assertion | [`rules/assertions.md`](rules/assertions.md) |
|
||||
| Endpoint coverage, authentication, authorization, tenant isolation, validation, and browser tests | [`rules/endpoint-tests.md`](rules/endpoint-tests.md) |
|
||||
| Factories, test data ownership, and repeated input values | [`rules/test-data.md`](rules/test-data.md) |
|
||||
| Fakes, mocks, outbound HTTP, time, randomness, and databases | [`rules/isolation.md`](rules/isolation.md) |
|
||||
| Escaping, injection, cross-tenant access, and privilege checks | [`rules/security.md`](rules/security.md) |
|
||||
| Environment and CI settings for a slow suite | [`rules/performance.md`](rules/performance.md) |
|
||||
| Reviewing a test or suite | [`rules/review.md`](rules/review.md) |
|
||||
@@ -0,0 +1,64 @@
|
||||
# Assertions
|
||||
|
||||
## Arrange, Act, Assert
|
||||
|
||||
Write each test in three parts: setup, one action, and assertions. Put one blank line between them so readers can identify each part without comments.
|
||||
|
||||
Keep each test self-contained. Do not use values created by another test.
|
||||
|
||||
## How to Find the Correct Assertion
|
||||
|
||||
First identify the subject of the check, then find an assertion designed for it. A subject-specific assertion identifies the incorrect value when the test fails.
|
||||
|
||||
1. Search Laravel's assertions for framework subjects such as responses, the database, sessions, models, queues, events, mail, and notifications.
|
||||
2. Fetch `https://pestphp.com/docs/expectations.md` for the expectations of Pest for a plain value, a type, a format, or a shape.
|
||||
3. Build the check by hand only if no assertion exists for the subject.
|
||||
4. Confirm the name in the documentation before you use it. Do not write an assertion that you did not confirm.
|
||||
|
||||
Use the assertion in this table for each subject.
|
||||
|
||||
| Subject | Assertion to use |
|
||||
| --- | --- |
|
||||
| A return value, the state of an object, or a transformation of a value | an `expect()` chain |
|
||||
| An HTTP status, JSON, a session, or Inertia | a Laravel response assertion |
|
||||
| The state in the database | a Laravel database assertion |
|
||||
| The existence of a model | `assertModelExists($model)` rather than `assertDatabaseHas('users', ['id' => $user->id])` |
|
||||
|
||||
Use a PHPUnit assertion only if no Pest expectation and no Laravel assertion exists for the subject.
|
||||
|
||||
Assert each fact once. Do not assert a 200 status before `assertSee`, because `assertSee` already shows that the page rendered.
|
||||
|
||||
## Named Response Assertions
|
||||
|
||||
Use a named response assertion, such as `assertNotFound()`, rather than `assertStatus(404)`. A failure then identifies the broken contract. Laravel provides named assertions for commonly tested status codes.
|
||||
|
||||
Keep one `expect()` chain on one subject. Start a new chain when the subject changes, or when the chain is difficult to read.
|
||||
|
||||
## Format Expectations
|
||||
|
||||
Use Pest's format expectations rather than regular expressions because they provide clearer failure messages. Pest covers email addresses, URLs, UUIDs, IP addresses, and other common formats, and each expectation supports `not` for the negative case.
|
||||
|
||||
## Assert a Known Value
|
||||
|
||||
Write the expected value in the test, or calculate the expected value by a different method. Do not calculate the expected value with the logic of the implementation, because the test then passes when that logic is wrong.
|
||||
|
||||
```php
|
||||
// The test calculates the value with the logic of the implementation...
|
||||
$expected = now()->subHours(24)->floorSeconds(30)->toJson();
|
||||
expect($from)->toBe($expected);
|
||||
|
||||
// The test sets a fixed input and asserts a known value...
|
||||
travelTo('2025-01-01 00:00:00');
|
||||
expect($from)->toBe('2024-12-31T00:00:00.000000Z');
|
||||
```
|
||||
|
||||
## Assert the Complete Result
|
||||
|
||||
A status code is not the complete result of a write operation. Assert each of the following if the operation changes it:
|
||||
|
||||
- The response or the return value.
|
||||
- The state in the database.
|
||||
- The jobs and the events that the operation dispatches.
|
||||
- The notifications and the mail that the operation sends.
|
||||
|
||||
On the failure path, assert that the operation makes none of these changes. A test that asserts only `assertOk()` passes even when the application saves no record.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Endpoint Tests
|
||||
|
||||
## How to Write the Test
|
||||
|
||||
Fetch `https://laravel.com/framework/docs/http-tests` for the request helpers, the authentication helpers, and the response assertions. Confirm the name before you use it, and do not guess an assertion.
|
||||
|
||||
Choose an assertion based on the subject of the check: the status, a header, a redirect, the JSON body, the session, a validation error, or the view. Laravel provides a named assertion for each subject that identifies the incorrect value.
|
||||
|
||||
## Endpoint Coverage
|
||||
|
||||
Write a test for each applicable case:
|
||||
|
||||
- The request has missing or invalid authentication.
|
||||
- The request comes from a different tenant, team, or organization.
|
||||
- The user has an insufficient role or permission.
|
||||
- The request does not satisfy a route or scope constraint.
|
||||
- The request fails the validation.
|
||||
- The request is valid. Assert both the response and the persisted state.
|
||||
|
||||
Assert the application's actual behavior rather than a generic status code. An API returns `401` for a missing or invalid token, while a browser endpoint redirects to the sign-in route.
|
||||
|
||||
## Tenant Isolation
|
||||
|
||||
Assert the status code returned for a cross-tenant request. Use `404` rather than `403` when one tenant must not learn that another tenant's record exists, because `403` confirms its existence.
|
||||
|
||||
## Test Authorization at the Policy Level
|
||||
|
||||
An HTTP test shows that the endpoint performs authorization. It cannot identify which mechanism refused the request because middleware, a policy, and a call to `abort()` can all return `403`.
|
||||
|
||||
- Assert the complete matrix of the permissions against the policy or the gate. A failure then names the rule that is not correct.
|
||||
- Write one HTTP test for one refused role, which shows that the endpoint calls the authorization.
|
||||
- Use the helper of the project that asserts the ability and the arguments of the gate, if such a helper exists.
|
||||
|
||||
## Browser Tests
|
||||
|
||||
Write a browser test only for JavaScript behavior that an HTTP test cannot reach, such as modal interaction, drag-and-drop, live search, or client-side validation. Browser tests are slower than HTTP tests and can fail for reasons unrelated to the code under test.
|
||||
|
||||
- Assert the state that the user can see, and assert the state in the database that the interaction saves.
|
||||
- Wait until the test reaches the required state. Do not wait for a fixed number of seconds, which can fail on a slower machine.
|
||||
- Call `assertNoJavaScriptErrors()` in each browser test. An error in the console is a defect.
|
||||
|
||||
### Where a Browser Test Lives and How to Run It
|
||||
|
||||
The plugin runs browser tests as normal Pest tests, so they need no separate suite. Put them in `tests/Browser` to separate them from faster tests and run the directory with one command.
|
||||
|
||||
- Run a browser test with `vendor/bin/pest tests/Browser`, and add `--parallel` for the complete suite.
|
||||
- Run `vendor/bin/pest --debug` to open the window of the browser and to pause at a failure. Use `--headed` to watch a run that passes.
|
||||
- Add `--browser firefox` or `--browser safari` to run the test in a different browser. The default browser is Chrome.
|
||||
- The run needs Playwright and a browser on the machine. Follow the plugin documentation for local and CI installation commands.
|
||||
- Fetch `https://pestphp.com/docs/browser-testing` for the interactions, the assertions, and the devices that the plugin gives.
|
||||
|
||||
### Browser Test Pitfalls
|
||||
|
||||
- The plugin waits five seconds for an element. Raise the value with `pest()->browser()->timeout(10000)` in `Pest.php` for a page that is slower, and do not add a wait for a number of seconds to the test.
|
||||
- Apply `RefreshDatabase` to the browser tests in `Pest.php`. A browser test hits the application through a real request, and the records that it leaves break the next test.
|
||||
- Add `tests/Browser/Screenshots` to `.gitignore`. A failure writes a screenshot, and the file is not part of the repository.
|
||||
- Give `withKeyDown()` a key code, such as `KeyA`. A letter such as `'a'` gives the lowercase character, whatever modifier the test holds.
|
||||
- Interact inside the callback of `withinFrame()`. An interaction outside the callback does not reach the frame.
|
||||
|
||||
## Testing Validation
|
||||
|
||||
- Write one test for each validation rule when each failure represents a separate contract.
|
||||
- Write one test with an empty payload to assert several required fields together.
|
||||
- Assert the text of the message that the user gets. A message that is present but wrong is a defect.
|
||||
- Use a dataset for input values that need the same setup and the same assertions.
|
||||
|
||||
Send an input value that is not valid through the application, and assert the error. Do not assert that an array of rules contains a string, because that assertion tests the declaration and not the behavior. Use such an assertion only for a rule that no request can reach, and write the reason in the test.
|
||||
|
||||
### Which Layer Owns Which Case
|
||||
|
||||
The rule-class test owns the matrix of values that pass and fail. The endpoint test proves that the endpoint applies the rule and that the user receives the message.
|
||||
|
||||
When both tests contain the matrix, move it to the rule-class test and retain one case in the endpoint test. Never remove the last case, because the rule-class test still passes if the request omits the rule. The same division applies to policies, scopes, and other classes called by a request.
|
||||
@@ -0,0 +1,37 @@
|
||||
# How to Find Test Framework Features
|
||||
|
||||
Pest adds features faster than this skill can list them. Find an existing feature before implementing the behavior by hand.
|
||||
|
||||
- Give `search-docs` the capability you need rather than the name of a function you remember. It returns features available in the installed version.
|
||||
- Fetch `https://pestphp.com/llms.txt` for the complete feature list and additions in each release.
|
||||
- If a search returns no results, tell the user that the installed version does not provide the feature. Do not write an API that you have not confirmed.
|
||||
|
||||
Search for a feature in this table before you write the code by hand.
|
||||
|
||||
| Work that you need | Term to search for |
|
||||
| --- | --- |
|
||||
| Run one test with many input values | datasets, bound datasets |
|
||||
| Assert over many values or over a collection | higher-order expectations |
|
||||
| Remove the same setup from each test in a file | hooks, higher-order tests |
|
||||
| Apply a convention to the complete codebase | architecture testing |
|
||||
| Measure if the suite finds a defect | mutation testing |
|
||||
| Find code with no types | type coverage |
|
||||
| Reduce the time of a slow suite | parallel, profiling |
|
||||
| Split the suite across CI jobs | sharding, `--update-shards` |
|
||||
| Run only the tests that a change affects | Test Impact Analysis, `--tia` |
|
||||
| Assert that a value has a known format | validation expectations |
|
||||
| Run one test while you debug | filtering, `--bail`, `--dirty` |
|
||||
|
||||
## Built-in Laravel Assertion Methods
|
||||
|
||||
Laravel provides assertions for each part of the framework. Fetch `https://laravel.com/framework/docs/testing` for the complete list, and search for an assertion before building a check by hand. Examples include `assertDatabaseHas()`, `assertModelExists()`, `assertSoftDeleted()`, response assertions such as `assertRedirectToRoute()` and `assertJsonPath()`, and fake assertions such as `Queue::assertPushed()` and `Notification::assertSentTo()`.
|
||||
|
||||
A hand-built check fails with `false is not true`, which identifies nothing. A framework assertion names the incorrect table, value, or response, so the failure indicates what to fix.
|
||||
|
||||
```php
|
||||
// The failure says that false is not true. Instead of this...
|
||||
expect(User::where('email', 'taylor@laravel.com')->exists())->toBeTrue();
|
||||
|
||||
// Use this... the failure names the table and the attributes that it did not find...
|
||||
$this->assertDatabaseHas('users', ['email' => 'taylor@laravel.com']);
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# Fakes, Mocks, and Determinism
|
||||
|
||||
Tests that depend on actual time, randomness, sleeping, or network calls can fail for reasons unrelated to the code under test. Control all four.
|
||||
|
||||
## How to Isolate a Dependency
|
||||
|
||||
Fetch `https://laravel.com/framework/docs/mocking` for Laravel's fakes, facade doubles, and fake assertions. Confirm each name before using it.
|
||||
|
||||
Identify the dependency, then choose the first applicable option. A framework fake preserves the real code path, while a mock replaces the dependency.
|
||||
|
||||
1. Always use framework fakes for facades such as events, queues, mail, notifications, storage, the HTTP client, time, and sleep.
|
||||
2. Use a developer-defined fake implementation of a service if the application provides one.
|
||||
3. Use a mock for a container-resolved contract only when the real implementation leaves the process or is nondeterministic.
|
||||
4. Use the real implementation for everything else, including the database.
|
||||
|
||||
## Framework Fakes
|
||||
|
||||
- Create each fake inside the test that needs it. Do not create fakes in a file-level `beforeEach()`.
|
||||
- Pass class names to `Event::fake()` and `Queue::fake()` when you know which classes the code dispatches. A fake without class names can hide an unexpected dispatch.
|
||||
- Use a fake without class names only when the test asserts the complete result, including a call to `assertNothingPushed()`.
|
||||
- Write one assertion for each fake. The assertion states that the code dispatches the item, or that the code does not dispatch the item.
|
||||
- Assert the data of a job or of an event if that data is part of the behavior.
|
||||
- Use `Exceptions::fake()` to assert that the application reports the correct exception. Do not use `withoutExceptionHandling()`, because it changes the response under test.
|
||||
|
||||
Create prerequisite factory records before calling `Event::fake()`. Factories use model events, such as a `creating` hook that generates a UUID, and a fake without class names suppresses those events and can produce an invalid model. Call the fake first only when a factory event is under test, and pass that event's class name.
|
||||
|
||||
## Mocking
|
||||
|
||||
Use `shouldReceive()` before the action to declare an expectation. Use `shouldHaveReceived()` after the action for a spy. Use `Mockery::on()` or `withArgs()` if an equality check cannot state the expected argument, such as a check of one field of a value object.
|
||||
|
||||
Import the mock function before you use it: `use function Pest\Laravel\mock;`.
|
||||
|
||||
## Outbound HTTP Testing
|
||||
|
||||
Call `Http::preventStrayRequests()`. Any request without a matching fake then fails without reaching the network.
|
||||
|
||||
Fake the exact endpoint used by each test. Do not call `Http::fake()` without an endpoint because it accepts unexpected requests and can hide defects.
|
||||
|
||||
## Time and Randomness
|
||||
|
||||
- Freeze the time or move the time in each test that depends on a date, a period, or a timestamp.
|
||||
- Use the framework helpers `freezeTime()`, `travelTo()`, `travel()`, and `travelBack()`. Do not call `Carbon::setTestNow()`.
|
||||
- Use `Str::createRandomStringsUsing()` to fix a generated string, if the test asserts an identifier or a slug.
|
||||
- Use `Sleep::fake()` instead of a real sleep, and assert the sleeps that the code requests.
|
||||
- Restore the time and the randomness after each test, if the suite does not restore them for every test.
|
||||
|
||||
## Database
|
||||
|
||||
- Run real queries against the real records in the test database. Do not mock the query builder, because the test then asserts the mock.
|
||||
- Assert the exact keys of `toArray()` if the shape of the serialized model is a contract. The test then fails when the model exposes a new attribute.
|
||||
- Test application behavior caused by the schema, such as deleting dependent records through a cascade. Do not test the database engine's cascade implementation.
|
||||
- Use `LazilyRefreshDatabase` instead of `RefreshDatabase`. A test that does not use the database then does not run the migrations.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Naming and Structure
|
||||
|
||||
## File Layout
|
||||
|
||||
- Name each test file `{ClassName}Test.php`.
|
||||
- Place each test file at the same relative path as the class under test. The class `app/Actions/DeleteTeam.php` gets the test `tests/Unit/Actions/DeleteTeamTest.php`.
|
||||
- Follow the project's convention for fixture files. If none exists, put fixtures in `tests/Fixtures/` and load them by path.
|
||||
- Move large literal values out of the test body and into fixture files.
|
||||
|
||||
## Test Function
|
||||
|
||||
Use the test function used by other files in the same directory. If no neighboring test files exist:
|
||||
|
||||
- Use `it()` for the behavior of the code, and write the name as a verb phrase.
|
||||
- Use `test()` for a declarative fact, such as a grant in a policy, the labels of an enum, or the shape of a serialized model.
|
||||
|
||||
Use one Pest declaration style in each file. Use either `it()` or `test()` consistently.
|
||||
|
||||
## Naming Tests
|
||||
|
||||
The name of a test is a specification. State the user-visible result and the condition that causes it.
|
||||
|
||||
- Name the behavior, and not the method under test. The file name already gives the class.
|
||||
- Give the exact status code in the name of a test for an API error.
|
||||
- Do not write `Given`, `When`, or `Then` in the name.
|
||||
|
||||
```php
|
||||
it('returns 401 when no token is provided', function () { ... });
|
||||
it('does not include deployments from deleted environments', function () { ... });
|
||||
it('falls back to the default region when none is configured', function () { ... });
|
||||
```
|
||||
|
||||
Use a verb that describes a result, such as `returns`, `renders`, `creates`, `dispatches`, `rejects`, `forbids`, `falls back`, or `does not`.
|
||||
|
||||
Do not write `it('works correctly')` or `it('returns data')`, because neither specifies a meaningful result. Do not write `it('handleMethod creates record')`, because it names a method rather than behavior.
|
||||
|
||||
## Grouping
|
||||
|
||||
Use `describe()` if one file covers separate actions in a lifecycle. An example is a controller with the actions `index`, `show`, `store`, `update`, and `destroy`.
|
||||
|
||||
Do not use `describe()` in these cases:
|
||||
|
||||
- The file covers one action or one flow.
|
||||
- The tests are different only in the input value. Use a dataset instead.
|
||||
- The group adds a level but does not make the file easier to read.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Test Suite Performance
|
||||
|
||||
These settings apply to the project and CI, not to individual tests. Read `rules/isolation.md` for choices within a test.
|
||||
|
||||
Fetch `https://pestphp.com/docs/optimizing-tests` for Pest options that make test runs faster.
|
||||
Verify each flag in the documentation before adding it to CI.
|
||||
|
||||
Measure before changing a setting. Find the slow test first, and apply a project-wide setting only after identifying the costly work.
|
||||
|
||||
## Test Environment
|
||||
|
||||
- Set `BCRYPT_ROUNDS=4` in `.env.testing` or in `phpunit.xml`. The default value is 12, and the hash then takes most of the time of each test that signs a user in.
|
||||
- Disable XDebug. Disable pcov also, unless the run needs the coverage.
|
||||
- Disable packages that perform work on every request in the test environment. Examples are Pulse, Telescope, and Nightwatch.
|
||||
- Use the `WithCachedConfig` and `WithCachedRoutes` traits, so the run does not parse the configuration and the routes for every test.
|
||||
- Call `withoutVite()`, or `withoutMix()`, so the framework does not resolve a built asset.
|
||||
|
||||
## Global Fakes
|
||||
|
||||
Put these three calls in the base `Pest.php` of the project:
|
||||
|
||||
- `Http::preventStrayRequests()`, because one request that reaches the network can slow the suite. This catches requests made through Laravel's HTTP client. Check direct Guzzle and cURL usage separately.
|
||||
- `Sleep::fake(syncWithCarbon: true)`, so a retry and a backoff do not sleep.
|
||||
- `Exceptions::fake()`, so the suite does not report an exception to an external service.
|
||||
|
||||
## How to Run the Suite in Parallel
|
||||
|
||||
Run `vendor/bin/pest --parallel` to spread tests across the machine's CPU cores. Add `--processes=N` if the default count is unsuitable for the machine or CI.
|
||||
|
||||
A parallel run gives each process a separate database. Tests must meet these conditions; a test that fails only in parallel breaks one of them:
|
||||
|
||||
- The test creates each record that it reads. It does not read a record that another test creates.
|
||||
- The test does not depend on the order of the run.
|
||||
- The test does not share a file, a cache key, or a queue with another test. Give each process a separate name for such a resource.
|
||||
|
||||
## How to Run Fewer Tests
|
||||
|
||||
Run `vendor/bin/pest --parallel --tia` to run only the tests that the recent changes affect. Pest replays the cached result of each other test.
|
||||
|
||||
Pest replays cached results rather than skipping unaffected tests. The cache includes each produced value and the covered lines and branches. Pest finds affected Laravel, Symfony, Livewire, and Inertia tests without configuration.
|
||||
|
||||
## How to Split Tests Across CI
|
||||
|
||||
Run `vendor/bin/pest --update-shards` to measure the time of each test. Run `vendor/bin/pest --shard=1/4` in each CI job, and change the first number for each job.
|
||||
|
||||
Commit `tests/.pest/shards.json` so each CI job gets the same shard and the shards remain balanced by runtime rather than test count.
|
||||
|
||||
## How to Find a Slow Test
|
||||
|
||||
Run `vendor/bin/pest --profile` to list the slowest tests. Start with the ten slowest tests, because the same cause often applies to the complete suite.
|
||||
|
||||
If the cause of a slow test is unclear, add an event listener or temporary log entry to identify its work.
|
||||
|
||||
## Common Errors
|
||||
|
||||
- The run loads XDebug for a test that does not need it.
|
||||
- `BCRYPT_ROUNDS` keeps the default value, because the project has no `.env.testing`.
|
||||
- The code under test calls the real `sleep()`, and `Sleep::fake()` then does not help.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Reviewing Tests
|
||||
|
||||
Check every item in this file. A passing test may still provide no value. For each test, identify the defect it would catch.
|
||||
|
||||
Report each finding. Do not delete or rewrite a test without the user's approval. When an issue appears throughout the suite as a convention, report the pattern once rather than every affected file.
|
||||
|
||||
## Test Value
|
||||
|
||||
Apply this section to behavioral tests. An architecture test states a convention for a directory, so these items do not apply to it.
|
||||
|
||||
- [ ] Each test covers observable behavior or an application contract, and passes after a change to the implementation that keeps the behavior.
|
||||
- [ ] Each tested declaration is exercised through behavior, and no test asserts the behavior of the framework. A test of what this project configures, such as a relation with a constraint, a cast, or a scope, belongs to this project.
|
||||
- [ ] Each test detects a distinct defect that no other test covers. A duplicate shrinks at the higher layer to the one case that proves the wiring.
|
||||
- [ ] Every changed decision and each applicable high-value failure mode has coverage.
|
||||
|
||||
## Names and Structure
|
||||
|
||||
- [ ] Each file has the name `{ClassName}Test.php` and the relative path of the class under test.
|
||||
- [ ] Each name states a result, the condition that causes it, and the status code for an API error.
|
||||
- [ ] Each file uses one declaration style consistently, and each `describe()` group holds separate behavior.
|
||||
|
||||
## Coverage
|
||||
|
||||
- [ ] HTTP tests cover authentication, authorization, role, scope, and validation when applicable.
|
||||
- [ ] A request for a record of a different tenant gets a status code that does not confirm that the record exists.
|
||||
- [ ] The complete permission matrix belongs in policy tests, not controller tests.
|
||||
- [ ] Each validation rule has one test that asserts the user-visible message. When a unit test owns a matrix, reduce duplicate higher-level coverage to one case rather than deleting it.
|
||||
- [ ] Rendered user input and each dynamic part of a query have a security test.
|
||||
|
||||
## Data and Determinism
|
||||
|
||||
- [ ] Each test creates its mutable records directly or through a helper that it calls, and every created record arranges the behavior or supports an assertion.
|
||||
- [ ] Each `beforeEach()` holds configuration only.
|
||||
- [ ] Each factory state and each relationship gives the meaning of the data.
|
||||
- [ ] Each call to `make()` is in a test that does not need the database.
|
||||
- [ ] Time, randomness, sleep, and outbound HTTP are controlled.
|
||||
- [ ] Each test passes alone, and passes in the complete suite in any order.
|
||||
|
||||
## Assertions
|
||||
|
||||
- [ ] Each expected value is a known value, and the test does not calculate the value with the logic of the implementation.
|
||||
- [ ] Each test of a write operation asserts the response, the state in the database, and the side effects.
|
||||
- [ ] Each fake has one assertion, and gives the class names unless the test asserts the complete result.
|
||||
- [ ] Each `expect()` chain stays on one subject.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Security Tests
|
||||
|
||||
Test each security boundary where user input affects authorization, rendered output, or query construction. A defect at such a boundary can be difficult to detect because the feature may continue to work.
|
||||
|
||||
Write a test for each of these cases:
|
||||
|
||||
- **Cross-tenant access.** Request a record of a different tenant, team, or organization. Read `rules/endpoint-tests.md` for why the response should possibly be `404` rather than `403`.
|
||||
- **Each unprivileged role.** Use a dataset over the roles that the endpoint must refuse.
|
||||
- **Escaping user-provided content.** Test escaping in HTML and mail. Include names and every free-text field a template renders. Assert that dangerous characters are escaped and the raw value is absent. Do not assert an exact entity for a quote, because Markdown and mail CSS inliners may decode it.
|
||||
- **Injection into dynamic query components.** Examples include sort columns, filter fields, and sort directions.
|
||||
- **An unexpected key** in a payload or configuration array. A merge that accepts every key can set an attribute the user must not control.
|
||||
|
||||
```php
|
||||
it('escapes dangerous content in the notification', function () {
|
||||
$organization = Organization::factory()->make([
|
||||
'name' => "O'Reilly <script>alert('xss')</script>",
|
||||
]);
|
||||
|
||||
$content = (new QuotaApproaching($organization, 80))->toMail()->render();
|
||||
|
||||
expect($content)
|
||||
->toContain('<script>')
|
||||
->not->toContain("<script>alert('xss')</script>");
|
||||
});
|
||||
```
|
||||
|
||||
Laravel provides defenses against mass assignment, unauthorized access, and unescaped output. Test that the application applies the appropriate defense to each attribute, route, and template.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Factories and Test Data
|
||||
|
||||
## Each Test Makes Its Own Data
|
||||
|
||||
Create mutable records inside the test that uses them. This keeps setup visible and lets each test select its factory state.
|
||||
|
||||
Use `beforeEach()` only for configuration that applies to every test in the file. Do not create records in it.
|
||||
|
||||
## Record Construction
|
||||
|
||||
- Use `create()` if the test needs the record in the database.
|
||||
- Use `make()` only if the test does not need the database. Examples include rendering a notification and testing a value object's behavior.
|
||||
- Use a named factory state instead of a raw attribute. `User::factory()->unverified()->create()` gives the state meaning; `create(['email_verified_at' => null])` gives only its value.
|
||||
- Use `for()` or the relationship helper of the project to declare the owner of a record.
|
||||
- Use `recycle()` if several records must share one parent record.
|
||||
- Use `sequence()` if several records need different attributes.
|
||||
|
||||
```php
|
||||
$organization = Organization::factory()->onPlan(BillingPlan::PRO)->create();
|
||||
|
||||
$environment = Environment::factory()->recycle($organization)->create();
|
||||
|
||||
$organizations = Organization::factory()
|
||||
->count(3)
|
||||
->sequence(
|
||||
['created_at' => now()->setSeconds(30)],
|
||||
['created_at' => now()->setSeconds(1)],
|
||||
)
|
||||
->create();
|
||||
```
|
||||
|
||||
Create only the records required to arrange the behavior or support an assertion.
|
||||
|
||||
## Datasets
|
||||
|
||||
Use a dataset when the setup, test body, and assertions remain the same across input values.
|
||||
|
||||
```php
|
||||
it('forbids roles other than admin', function (Role $role) {
|
||||
actingAs(User::factory()->hasOrganization($role)->create())
|
||||
->post('/settings')
|
||||
->assertForbidden();
|
||||
})->with(collect(Role::cases())->reject(fn (Role $role) => $role === Role::ADMIN));
|
||||
```
|
||||
|
||||
Use parameterized tests for:
|
||||
|
||||
- enum cases
|
||||
- roles and plans
|
||||
- boundary values
|
||||
- input values that are invalid in the same way
|
||||
- input and output value pairs
|
||||
|
||||
Write separate tests if the cases need a different setup, a different behavior, or different assertions. One test function with a branch in the body is two tests in one function.
|
||||
|
||||
Give each dataset case a name that states the difference. A failure then identifies the case without requiring you to count positions.
|
||||
+4
-1
@@ -21,10 +21,13 @@ docker-compose*.yml
|
||||
Dockerfile
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitea
|
||||
|
||||
# Testing
|
||||
tests
|
||||
|
||||
# Website (published separately)
|
||||
website
|
||||
phpunit.xml
|
||||
.phpunit.cache
|
||||
|
||||
|
||||
+1
-1
@@ -8,4 +8,4 @@
|
||||
|
||||
CHANGELOG.md export-ignore
|
||||
README.md export-ignore
|
||||
.github/workflows/browser-tests.yml export-ignore
|
||||
.gitea export-ignore
|
||||
|
||||
@@ -42,9 +42,6 @@ jobs:
|
||||
- name: Install Node Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Add Flux Credentials Loaded From ENV
|
||||
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
@@ -57,6 +54,9 @@ jobs:
|
||||
- name: Build Assets
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Tests
|
||||
run: ./vendor/bin/pest
|
||||
|
||||
@@ -67,25 +67,33 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# The runner is an arm64 server, so the amd64 image's final stage is emulated. QEMU 8.x
|
||||
# crashes running x86_64 programs on an arm64 host (QEMU issue 2168, "QEMU internal
|
||||
# SIGSEGV {code=MAPERR, addr=0x20}") and 10.2 segfaults on this runner too; 9.2.2 was
|
||||
# checked on the runner's host: node, composer and install-php-extensions all run.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v9.2.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
# Gitea's job token cannot publish packages yet: REGISTRY_TOKEN is an access token with
|
||||
# package write rights, owned by the account that pushes (gitea.actor).
|
||||
- name: Log in to the Gitea container registry
|
||||
if: gitea.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: gitea.nonameweb.ch
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
images: gitea.nonameweb.ch/nonameweb/sealshare
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
@@ -98,21 +106,27 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=registry,ref=gitea.nonameweb.ch/nonameweb/sealshare:buildcache
|
||||
cache-to: ${{ gitea.event_name != 'pull_request' && 'type=registry,ref=gitea.nonameweb.ch/nonameweb/sealshare:buildcache,mode=max' || '' }}
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: Take the release notes from the changelog
|
||||
run: |
|
||||
version="${{ gitea.ref_name }}"
|
||||
awk -v heading="## [${version#v}]" 'index($0, heading) == 1 { found = 1; next } found && /^## \[/ { exit } found { print }' CHANGELOG.md > release-notes.md
|
||||
test -s release-notes.md
|
||||
|
||||
- name: Create the Gitea release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
generate_release_notes: true
|
||||
body_path: release-notes.md
|
||||
@@ -29,9 +29,6 @@ jobs:
|
||||
with:
|
||||
php-version: '8.5'
|
||||
|
||||
- name: Add Flux Credentials Loaded From ENV
|
||||
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
@@ -47,4 +44,4 @@ jobs:
|
||||
# commit_options: '--no-verify'
|
||||
# file_pattern: |
|
||||
# **/*
|
||||
# !.github/workflows/*
|
||||
# !.gitea/workflows/*
|
||||
@@ -42,9 +42,6 @@ jobs:
|
||||
- name: Install Node Dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Add Flux Credentials Loaded From ENV
|
||||
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
@@ -57,5 +54,8 @@ jobs:
|
||||
- name: Build Assets
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Tests
|
||||
run: ./vendor/bin/pest
|
||||
@@ -25,3 +25,5 @@ yarn-error.log
|
||||
**/caddy
|
||||
frankenphp
|
||||
frankenphp-worker.php
|
||||
|
||||
/tests/Browser/Screenshots
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.0.0] - 2026-09-13
|
||||
|
||||
### Added
|
||||
|
||||
- Eight colour profiles — Indigo (the default), Blue, Teal, Green, Amber, Rose, Violet and Graphite. An admin picks one in Admin settings, previews it on the page, and after saving every page, mail and error page uses it; light and dark stay each visitor's own choice.
|
||||
- The share created page offers the link as a QR code: "Show QR code" opens it in a dialog (full screen on a phone) and "Download" saves it as a PNG. For a password-protected share the dialog reminds that recipients also need the password; the code holds only the link.
|
||||
- A "Share…" button on the same page opens the device's share sheet with the link, where the browser has one (mostly phones and Safari).
|
||||
|
||||
### Changed
|
||||
|
||||
- SealShare moved from GitHub to Gitea: the source is at https://gitea.nonameweb.ch/noNameWEB/SealShare, and the Docker image is published as `gitea.nonameweb.ch/nonameweb/sealshare`. Images at `ghcr.io/surtic86/sealshare` are no longer updated — change `image:` in your `docker-compose.yml` to the new name to keep receiving releases.
|
||||
- The interface is rebuilt on [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material), a Material 3 Expressive component library, replacing Mary UI and DaisyUI. Every page — upload, share created, download, sign-in, settings, admin and the setup wizard — uses its components, in a colour scheme generated from SealShare's indigo.
|
||||
- The theme follows the system's light or dark setting until a user picks Light, Dark or System in Settings → Appearance, or from the account menu. A theme chosen in 1.x is kept.
|
||||
- A floating toolbar centred at the bottom of every page replaces the sidebar and header layouts. Signed-in users reach Upload and the admin pages from it, and Settings, the theme and Log out from its account menu. The site's name and logo head the upload and download pages.
|
||||
- Confirmations for deleting a share, removing the logo, clearing the system password and deleting the account are dialogs instead of browser prompts, and "Saved." messages are snackbars.
|
||||
- The two-factor setup opens full screen on a phone.
|
||||
- HTTP error pages and Markdown mail (password reset, email verification) use the same Material design and colours. Set `MAIL_MARKDOWN_THEME=default` to get Laravel's mail theme back.
|
||||
- Signing in now lands on the admin dashboard. The starter kit's placeholder `/dashboard` page is gone.
|
||||
- The Docker build installs Composer packages before building the frontend, because the stylesheet imports Livewire Material from `vendor/`.
|
||||
- Removed the dependencies `robsontenorio/mary`, `daisyui` and `alpinejs` (Livewire bundles Alpine). The Bunny Fonts request is gone.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The README called SealShare's encryption end-to-end. Files are encrypted at rest on the server; the README now says so, and that a share password's key is never stored.
|
||||
- The admin dashboard passed its sort column and direction straight to the query, so a crafted Livewire request could order shares by any column or cause a server error. It now sorts only by the columns it shows and otherwise falls back to newest first.
|
||||
|
||||
## [1.2.0] - 2026-09-10
|
||||
|
||||
### Added
|
||||
|
||||
- `LIVEWIRE_MAX_UPLOAD_TIME` (minutes, default 30) sets how long a single upload may take before its signed upload URL expires. Raise it when large files arrive over slow connections.
|
||||
- A "Large files" section in the README listing every limit that has to be raised together for big uploads: PHP, admin settings, upload time, execution time and the reverse proxy.
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded to Pest 5.1 (`pestphp/pest` ^5.1, `pestphp/pest-plugin-laravel` ^5.0), which brings PHPUnit 13.3. The test suite needed no changes.
|
||||
- Updated PHP dependencies within their existing constraints: Laravel 13.31, Livewire 4.4.4, Octane 2.19.1, Fortify 1.39, Mary 2.9.10, Boost 2.8 and Pint 1.32. Guzzle moves to 8.2 as a transitive dependency of Laravel.
|
||||
- Updated frontend dependencies: Alpine.js 3.17.2, DaisyUI 5.7.32, Vite 8.2.2 and `laravel-vite-plugin` 3.2.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Files larger than 4 GB were always rejected with "Upload failed: file exceeds the maximum size of N MB", even when the admin's maximum file size N was bigger than the file. Livewire's temporary upload rule had a hard-coded 4 GB cap; it is gone, so `PHP_UPLOAD_MAX_FILESIZE` is the hard limit and the admin setting is the enforced one.
|
||||
- An upload the server rejects no longer blames the admin's maximum file size. The user is told the server could not accept the file, and the actual reason is logged as a warning.
|
||||
|
||||
## [1.1.0] - 2026-07-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded to Laravel 13 (`laravel/framework` ^13.0, `laravel/tinker` ^3.0). PHP 8.5 is now the minimum.
|
||||
- Set `serializable_classes` to `false` in `config/cache.php`, so a leaked `APP_KEY` cannot drive an object gadget chain through the cache.
|
||||
- Upgraded the frontend toolchain to match the Laravel 13 skeleton: Vite 8, `laravel-vite-plugin` 3, Tailwind CSS 4.3.3, DaisyUI 5.7, Alpine.js 3.15.12 and concurrently 10.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Adding a second batch of files to a share left the uploader stuck on "Processing files…" forever, with the drop zone and the "Create Share Link" button permanently disabled. The uploading state is now cleared by a `files-processed` event dispatched on every batch, instead of a one-off `x-init` that only ran the first time the file list appeared.
|
||||
- Dropping files on the drop zone showed no upload progress at all, because `uploadMultiple()` was called without progress callbacks.
|
||||
- Dropping a second folder onto an existing selection replaced the collected relative paths instead of appending them, which shifted every earlier file's path onto the wrong file.
|
||||
|
||||
### Removed
|
||||
|
||||
- Dropped the unused `axios` dependency and the stale `@rollup/rollup-linux-x64-gnu` optional pin (Vite 8 builds with rolldown).
|
||||
|
||||
### Security
|
||||
|
||||
- Forced `shell-quote` to a patched release via an npm override, clearing GHSA-395f-4hp3-45gv (quadratic complexity DoS). `npm audit` reports 0 vulnerabilities, down from 3.
|
||||
|
||||
## [1.0.1] - 2026-02-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- ZIP downloads returned 0-byte archives under FrankenPHP. ZipStream writes through `fwrite(php://output)`, which FrankenPHP silently drops; downloads are now built with native `ZipArchive` and served as a file response.
|
||||
- Docker image was missing the PHP `zip` extension required by `ZipArchive`.
|
||||
- Stale `bootstrap/cache/*.php` from the build context could load dev-only service providers in the production image.
|
||||
- `DB_DATABASE` now defaults to `/app/database/database.sqlite` in `docker-compose.yml`, so the `env()` fallback resolves correctly.
|
||||
- The unlock button on the password-protected share page rendered outside the form and did nothing. Share page action buttons are now consistently full width.
|
||||
|
||||
### Removed
|
||||
|
||||
- `maennchen/zipstream-php` dependency.
|
||||
|
||||
## [1.0.0] - 2026-02-13
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
- File uploading via drag & drop or browse, supporting multiple files and folders with real-time progress.
|
||||
- Shareable links, one unique link per upload.
|
||||
- AES-256-GCM encryption at rest, chunked and streaming, with PBKDF2-SHA256 key derivation.
|
||||
- Optional password protection per share.
|
||||
- Configurable expiration from 1 hour to 30 days, and per-share download limits.
|
||||
- ZIP download of all files in a share.
|
||||
- Hourly auto-cleanup of expired shares and their files.
|
||||
- Admin dashboard and settings for upload limits, storage quotas and branding.
|
||||
- Site branding: custom logo, title and description.
|
||||
- Optional system password gate restricting upload access.
|
||||
- User authentication (login, registration, password reset, email verification) and TOTP two-factor authentication via Laravel Fortify.
|
||||
- First-run setup wizard for creating the initial admin account.
|
||||
- Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI.
|
||||
- Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane.
|
||||
|
||||
[Unreleased]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...main
|
||||
[2.0.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v2.0.0
|
||||
@@ -7,23 +7,11 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||
|
||||
- php - 8.5
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v13
|
||||
- laravel/octane (OCTANE) - v2
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- livewire/livewire (LIVEWIRE) - v4
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- alpinejs (ALPINEJS) - v3
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
Before relying on a package's API, confirm its installed version:
|
||||
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||
- JS packages: check `package.json` for the installed versions.
|
||||
|
||||
## Skills Activation
|
||||
|
||||
@@ -70,7 +58,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
|
||||
## Searching Documentation (IMPORTANT)
|
||||
|
||||
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
|
||||
- Use `search-docs` before changes that depend on Laravel ecosystem APIs, behavior, configuration, or version-specific syntax. Skip it for copy-only edits and other changes where package documentation is irrelevant. Reuse sufficient results already in context instead of searching again.
|
||||
- Pass a `packages` array to scope results when you know which packages are relevant.
|
||||
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
|
||||
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
|
||||
@@ -82,6 +70,11 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||
|
||||
## Project Rules
|
||||
|
||||
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
|
||||
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||
@@ -110,13 +103,16 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
# Deployment
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
- Activate the `deploying-to-cloud` skill whenever deploying to Laravel Cloud, configuring Cloud environments or resources, using the Cloud CLI, or troubleshooting Cloud deployments.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
- Test every code change by adding or updating a test.
|
||||
- Run the affected tests and ensure they pass.
|
||||
- Test the changed behavior and its important failure modes, but do not add tests beyond them.
|
||||
- Read the `testing-best-practices` skill before writing tests.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
@@ -148,7 +144,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== octane/core rules ===
|
||||
=== laravel-octane/core rules ===
|
||||
|
||||
# Laravel Octane
|
||||
|
||||
@@ -164,7 +160,7 @@ When working on Octane-specific features (concurrency, shared tables, memory, dr
|
||||
|
||||
# Livewire
|
||||
|
||||
- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
|
||||
- Livewire allows you to build dynamic, reactive interfaces in PHP without writing JavaScript.
|
||||
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
|
||||
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
|
||||
|
||||
@@ -177,11 +173,30 @@ When working on Octane-specific features (concurrency, shared tables, memory, dr
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
# Pest
|
||||
|
||||
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
|
||||
- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`.
|
||||
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
|
||||
- Do NOT delete tests without approval.
|
||||
- This project uses Pest. Create tests with `php artisan make:test --pest {name}`.
|
||||
- Do not include the test suite directory in `{name}`. Use `SomeFeatureTest`, not `Feature/SomeFeatureTest`.
|
||||
- Read the `testing-best-practices` skill for guidance on coverage, naming, structure, dependency isolation, and review.
|
||||
- Do not delete tests or test files without approval. They are part of the application.
|
||||
|
||||
## Running Tests
|
||||
|
||||
- Run the narrowest set of tests that covers the change. Pass a file path or `--filter=testName` to `php artisan test --compact`.
|
||||
- Rerun a test after each change to it.
|
||||
- Run `vendor/bin/pest` to call the test runner directly. It accepts the same file path and `--filter=testName` arguments.
|
||||
- After the feature tests pass, ask the user to run the complete suite with `php artisan test --compact`.
|
||||
|
||||
=== nonameweb/livewire-material/core rules ===
|
||||
|
||||
## Livewire Material
|
||||
|
||||
This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, built on Tailwind CSS. It replaces UI kits such as maryUI, daisyUI and Flux in this application.
|
||||
|
||||
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
|
||||
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`…). They compile to nothing and fail silently.
|
||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
|
||||
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
|
||||
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
+24
-19
@@ -1,22 +1,9 @@
|
||||
# ============================================
|
||||
# Stage 1: Build frontend assets
|
||||
# Stage 1: Install PHP dependencies
|
||||
# ============================================
|
||||
FROM node:24-alpine AS assets
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --prefer-offline
|
||||
|
||||
COPY vite.config.js ./
|
||||
COPY resources/ ./resources/
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ============================================
|
||||
# Stage 2: Install PHP dependencies
|
||||
# ============================================
|
||||
FROM composer:2 AS vendor
|
||||
# Built on the build machine's own platform: vendor/ is plain PHP, the same for every target, so a
|
||||
# multi-arch build runs it once and never under emulation.
|
||||
FROM --platform=$BUILDPLATFORM composer:2 AS vendor
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -33,13 +20,31 @@ COPY . .
|
||||
|
||||
RUN composer dump-autoload --optimize --no-dev
|
||||
|
||||
# ============================================
|
||||
# Stage 2: Build frontend assets
|
||||
# ============================================
|
||||
# After Composer: the stylesheet and script import Livewire Material from vendor/. On the build
|
||||
# machine's platform too: the output is CSS and JavaScript, whatever the target.
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS assets
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --prefer-offline
|
||||
|
||||
COPY vite.config.js ./
|
||||
COPY resources/ ./resources/
|
||||
COPY --from=vendor /app/vendor/nonameweb ./vendor/nonameweb
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ============================================
|
||||
# Stage 3: Production image (FrankenPHP/Octane)
|
||||
# ============================================
|
||||
FROM dunglas/frankenphp:php8.5-alpine AS production
|
||||
|
||||
LABEL maintainer="surtic86"
|
||||
LABEL org.opencontainers.image.source="https://github.com/surtic86/SealShare"
|
||||
LABEL org.opencontainers.image.source="https://gitea.nonameweb.ch/noNameWEB/SealShare"
|
||||
LABEL org.opencontainers.image.description="Self-hosted encrypted file sharing"
|
||||
|
||||
# Install required PHP extensions
|
||||
@@ -82,7 +87,7 @@ COPY --from=vendor /app/vendor ./vendor
|
||||
COPY --from=assets /app/public/build ./public/build
|
||||
|
||||
# Remove dev/build files and stale cache not needed in production
|
||||
RUN rm -rf node_modules tests .github docker/dev.Dockerfile docker/dev-entrypoint.sh .env .env.example \
|
||||
RUN rm -rf node_modules tests .gitea docker/dev.Dockerfile docker/dev-entrypoint.sh .env .env.example \
|
||||
bootstrap/cache/*.php \
|
||||
&& mkdir -p storage/app/shares storage/app/public storage/framework/cache \
|
||||
storage/framework/sessions storage/framework/testing storage/framework/views \
|
||||
|
||||
@@ -2,11 +2,23 @@
|
||||
|
||||
A simple, self-hosted file sharing solution built with Laravel. Upload files, get a shareable link, done. All files are encrypted at rest with AES-256-GCM.
|
||||
|
||||
**Website:** [sealshare.nonameweb.ch](https://sealshare.nonameweb.ch)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p>
|
||||
<img src="website/img/screenshots/desktop/light/01-upload-800.webp" alt="Uploading files and folders with share options" width="49%">
|
||||
<img src="website/img/screenshots/desktop/light/02-share-created-800.webp" alt="A new share with its link and QR code button" width="49%">
|
||||
</p>
|
||||
<p>
|
||||
<img src="website/img/screenshots/phone/light/03-download-540.webp" alt="The recipient's download page on a phone" width="30%">
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- **File Uploading** — Drag & drop or browse to upload single/multiple files and folders with real-time progress
|
||||
- **Shareable Links** — Each upload generates a unique link for recipients
|
||||
- **End-to-End Encryption** — All files encrypted at rest using AES-256-GCM (chunked, streaming)
|
||||
- **Shareable Links** — Each upload generates a unique link for recipients, also as a QR code (saved as a PNG) or through the device's share sheet
|
||||
- **Encryption at Rest** — Files are encrypted on the server as they arrive, with AES-256-GCM (chunked, streaming); with a share password the key is derived from it and never stored. It is not end-to-end encryption: the server handles the files unencrypted while they are uploaded and downloaded
|
||||
- **Password Protection** — Optionally protect shares with a password
|
||||
- **Expiration** — Shares auto-expire after a configurable duration (1 hour to 30 days)
|
||||
- **Download Limits** — Set a maximum number of downloads per share
|
||||
@@ -15,23 +27,24 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|
||||
- **Admin Dashboard** — View, manage, and delete all shares
|
||||
- **Admin Settings** — Configure upload limits, storage quotas, branding, and more
|
||||
- **Site Branding** — Custom logo, title, and description
|
||||
- **Colour Profiles** — Eight Material 3 colour profiles (Indigo, Blue, Teal, Green, Amber, Rose, Violet, Graphite); the admin picks one for every page, mail and error page
|
||||
- **System Password** — Optional global password gate to restrict upload access
|
||||
- **User Authentication** — Login, registration, password reset, email verification
|
||||
- **User Authentication** — Login, password reset, email verification
|
||||
- **Two-Factor Authentication** — TOTP-based 2FA via Laravel Fortify
|
||||
- **Dark Mode** — Dark themed UI with DaisyUI components
|
||||
- **Light and Dark Themes** — Material 3 Expressive design that follows the system theme, or light or dark by choice
|
||||
- **Setup Wizard** — First-run wizard to create the initial admin account
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| **Framework** | Laravel 12 |
|
||||
| **Framework** | Laravel 13 |
|
||||
| **Application Server** | FrankenPHP (via Laravel Octane) |
|
||||
| **Frontend** | Livewire 4, Alpine.js, Tailwind CSS 4, DaisyUI 5, Mary UI |
|
||||
| **Frontend** | Livewire 4, Tailwind CSS 4, [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material) (Material 3 Expressive) |
|
||||
| **Authentication** | Laravel Fortify |
|
||||
| **Encryption** | Chunked AES-256-GCM with PBKDF2-SHA256 key derivation |
|
||||
| **ZIP Streaming** | maennchen/zipstream-php |
|
||||
| **Testing** | Pest 4 |
|
||||
| **ZIP Downloads** | Native PHP ZipArchive |
|
||||
| **Testing** | Pest 5 with browser tests (Playwright) |
|
||||
| **Code Style** | Laravel Pint |
|
||||
| **Build Tool** | Vite |
|
||||
|
||||
@@ -56,11 +69,11 @@ The app is available at `http://localhost:8000` with Vite HMR on port `5173`.
|
||||
|
||||
```bash
|
||||
mkdir sealshare && cd sealshare
|
||||
curl -O https://raw.githubusercontent.com/surtic86/SealShare/main/docker-compose.example.yml
|
||||
curl -O https://gitea.nonameweb.ch/noNameWEB/SealShare/raw/branch/main/docker-compose.example.yml
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
|
||||
# Generate an app key and paste it into docker-compose.yml
|
||||
docker run --rm ghcr.io/surtic86/sealshare:latest php artisan key:generate --show
|
||||
docker run --rm gitea.nonameweb.ch/nonameweb/sealshare:latest php artisan key:generate --show
|
||||
|
||||
# Edit docker-compose.yml — set APP_KEY, APP_URL, and SERVER_NAME
|
||||
# Then start:
|
||||
@@ -86,10 +99,23 @@ Migrations run automatically on startup. Open your configured domain — the Set
|
||||
| `caddy_data` | `/data` | TLS certificates |
|
||||
| `caddy_config` | `/config` | Caddy configuration |
|
||||
|
||||
**Large files:**
|
||||
|
||||
Uploads beyond the defaults need these limits raised together:
|
||||
|
||||
| Limit | Where | Default |
|
||||
|-------|-------|---------|
|
||||
| `PHP_UPLOAD_MAX_FILESIZE` / `PHP_POST_MAX_SIZE` | Environment | `4G` — hard cap per file / per upload batch |
|
||||
| Max file size / Max size per share | Admin → Settings | 100 MB / 2 GB |
|
||||
| `LIVEWIRE_MAX_UPLOAD_TIME` | Environment | 30 minutes per upload |
|
||||
| `OCTANE_MAX_EXECUTION_TIME` / `PHP_MAX_EXECUTION_TIME` | Environment | 300 seconds — encrypting a large file takes a while |
|
||||
|
||||
Behind a reverse proxy, raise its request body limit and read timeout as well (nginx: `client_max_body_size`, `proxy_read_timeout`).
|
||||
|
||||
### Manual (without Docker)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/surtic86/SealShare.git
|
||||
git clone https://gitea.nonameweb.ch/noNameWEB/SealShare.git
|
||||
cd SealShare
|
||||
|
||||
composer install --no-dev --optimize-autoloader
|
||||
|
||||
@@ -14,37 +14,38 @@ class AdminDashboard extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
/** @var array<string, string> */
|
||||
/**
|
||||
* The columns the table can be sorted by.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const SORTABLE = ['token', 'files_count', 'total_size', 'download_count', 'expires_at', 'created_at'];
|
||||
|
||||
/** @var array{column: string, direction: string} */
|
||||
public array $sortBy = ['column' => 'created_at', 'direction' => 'desc'];
|
||||
|
||||
/** The share the delete dialog is asking about, while it is open. */
|
||||
public ?int $deletingShareId = null;
|
||||
|
||||
public function deleteShare(int $shareId, ShareService $shareService): void
|
||||
{
|
||||
$share = Share::query()->findOrFail($shareId);
|
||||
$shareService->deleteShare($share);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string|bool>>
|
||||
*/
|
||||
public function headers(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'token', 'label' => __('Token')],
|
||||
['key' => 'files_count', 'label' => __('Files')],
|
||||
['key' => 'total_size', 'label' => __('Size')],
|
||||
['key' => 'download_count', 'label' => __('Downloads')],
|
||||
['key' => 'expires_at', 'label' => __('Expires')],
|
||||
['key' => 'created_at', 'label' => __('Created')],
|
||||
];
|
||||
$this->deletingShareId = null;
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
$shareService = app(ShareService::class);
|
||||
|
||||
// The sort comes from the browser: only a known column and direction reach the query.
|
||||
$column = in_array($this->sortBy['column'] ?? null, self::SORTABLE, true) ? $this->sortBy['column'] : 'created_at';
|
||||
$direction = ($this->sortBy['direction'] ?? null) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$shares = Share::query()
|
||||
->withCount('files')
|
||||
->orderBy($this->sortBy['column'], $this->sortBy['direction'])
|
||||
->orderBy($column, $direction)
|
||||
->paginate(15);
|
||||
|
||||
return view('livewire.admin.admin-dashboard', [
|
||||
@@ -56,7 +57,6 @@ class AdminDashboard extends Component
|
||||
'totalFiles' => ShareFile::query()->count(),
|
||||
'usedSpace' => $shareService->getTotalUsedSpace(),
|
||||
'maxQuota' => $shareService->getMaxStorageQuota(),
|
||||
'headers' => $this->headers(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,22 @@ namespace App\Livewire\Admin;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class AdminSettings extends Component
|
||||
{
|
||||
use Toasts;
|
||||
use WithFileUploads;
|
||||
|
||||
/** The colour profile every page, mail and error page wears (config/livewire-material.php). */
|
||||
public string $colorProfile = '';
|
||||
|
||||
public string $systemPassword = '';
|
||||
|
||||
public string $defaultExpiration = '';
|
||||
@@ -34,8 +41,15 @@ class AdminSettings extends Component
|
||||
|
||||
public $siteLogo;
|
||||
|
||||
/** Whether the "Remove the logo?" dialog is open. */
|
||||
public bool $confirmingLogoRemoval = false;
|
||||
|
||||
/** Whether the "Remove the system password?" dialog is open. */
|
||||
public bool $confirmingPasswordRemoval = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->colorProfile = Scheme::profile() ?? '';
|
||||
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
|
||||
$this->maxFileSize = min(
|
||||
(int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024),
|
||||
@@ -75,6 +89,7 @@ class AdminSettings extends Component
|
||||
$phpMaxMb = self::phpMaxUploadMb();
|
||||
|
||||
$this->validate([
|
||||
'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))],
|
||||
'maxFileSize' => ['required', 'integer', 'min:1', 'max:'.$phpMaxMb],
|
||||
'maxStorageQuota' => ['required', 'integer', 'min:1'],
|
||||
'maxFilesPerShare' => ['required', 'integer', 'min:1'],
|
||||
@@ -90,6 +105,7 @@ class AdminSettings extends Component
|
||||
Setting::set('system_password', Hash::make($this->systemPassword));
|
||||
}
|
||||
|
||||
Setting::set('color_profile', $this->colorProfile);
|
||||
Setting::set('default_expiration', $this->defaultExpiration ?: null);
|
||||
Setting::set('max_file_size', $this->maxFileSize * 1024 * 1024);
|
||||
Setting::set('max_storage_quota', $this->maxStorageQuota * 1024 * 1024 * 1024);
|
||||
@@ -113,7 +129,7 @@ class AdminSettings extends Component
|
||||
|
||||
$this->systemPassword = '';
|
||||
|
||||
session()->flash('message', __('Settings saved successfully.'));
|
||||
$this->success(__('Settings saved successfully.'));
|
||||
}
|
||||
|
||||
public function removeLogo(): void
|
||||
@@ -125,14 +141,18 @@ class AdminSettings extends Component
|
||||
Setting::set('site_logo', null);
|
||||
}
|
||||
|
||||
session()->flash('message', __('Logo removed.'));
|
||||
$this->confirmingLogoRemoval = false;
|
||||
|
||||
$this->success(__('Logo removed.'));
|
||||
}
|
||||
|
||||
public function clearSystemPassword(): void
|
||||
{
|
||||
Setting::set('system_password', null);
|
||||
|
||||
session()->flash('message', __('System password cleared.'));
|
||||
$this->confirmingPasswordRemoval = false;
|
||||
|
||||
$this->success(__('System password cleared.'));
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
@@ -36,37 +37,45 @@ class FileUploader extends Component
|
||||
$this->expiration = Setting::get('default_expiration', '7d') ?: '7d';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an upload the temporary upload endpoint did not accept.
|
||||
*
|
||||
* Validation errors (a 422) mean the whole file reached the server and was
|
||||
* rejected there, so the real reason is logged for the administrator rather
|
||||
* than guessed at in front of the user. Anything else is a transport failure.
|
||||
*/
|
||||
public function _uploadErrored($name, $errorsInJson, $isMultiple): void
|
||||
{
|
||||
$this->dispatch('upload:errored', name: $name)->self();
|
||||
|
||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||
$maxFileSizeMb = (int) ($maxFileSize / (1024 * 1024));
|
||||
$errors = is_null($errorsInJson) ? null : (json_decode($errorsInJson, true)['errors'] ?? null);
|
||||
|
||||
if (! is_null($errorsInJson)) {
|
||||
$errors = json_decode($errorsInJson, true)['errors'] ?? null;
|
||||
if ($errors) {
|
||||
Log::warning('File upload rejected by the temporary upload endpoint.', ['errors' => $errors]);
|
||||
|
||||
if ($errors) {
|
||||
$messages = [];
|
||||
foreach ($errors as $messages_array) {
|
||||
foreach ((array) $messages_array as $msg) {
|
||||
$messages[] = $msg;
|
||||
}
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'files' => __('Upload failed: file exceeds the maximum size of :max MB.', ['max' => $maxFileSizeMb]),
|
||||
]);
|
||||
}
|
||||
throw ValidationException::withMessages([
|
||||
'files' => __('Upload failed: the server could not accept the file. Please try again or contact the administrator.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$maxFileSizeMb = (int) ((int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024));
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'files' => __('Upload failed: file may be too large (max :max MB) or the connection was interrupted.', ['max' => $maxFileSizeMb]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a freshly uploaded batch of files.
|
||||
*
|
||||
* Dispatches `files-processed` so the front end can drop its "uploading" state.
|
||||
* This runs for every batch, including additional files added to an existing
|
||||
* selection, which a one-off `x-init` on the file list cannot cover.
|
||||
*/
|
||||
public function updatedFiles(): void
|
||||
{
|
||||
$this->dispatch('files-processed')->self();
|
||||
|
||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||
$maxFileSizeMb = $maxFileSize / (1024 * 1024);
|
||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Services\QrCodeService;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -18,6 +20,12 @@ class ShareCreated extends Component
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
return view('livewire.share-created');
|
||||
$shareUrl = route('share.download', $this->share);
|
||||
|
||||
return view('livewire.share-created', [
|
||||
'shareUrl' => $shareUrl,
|
||||
'qrCodeSvg' => app(QrCodeService::class)->svg($shareUrl),
|
||||
'siteTitle' => Setting::get('site_title') ?: config('app.name'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -24,6 +26,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureDefaults();
|
||||
|
||||
// The colour profile the admin chose in Admin settings; asked each time a page, mail or
|
||||
// error page draws its colours, so a new choice applies at once in every Octane worker.
|
||||
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,7 +49,6 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
Fortify::verifyEmailView(fn () => view('pages::auth.verify-email'));
|
||||
Fortify::twoFactorChallengeView(fn () => view('pages::auth.two-factor-challenge'));
|
||||
Fortify::confirmPasswordView(fn () => view('pages::auth.confirm-password'));
|
||||
Fortify::registerView(fn () => view('pages::auth.register'));
|
||||
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
|
||||
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use BaconQrCode\Common\ErrorCorrectionLevel;
|
||||
use BaconQrCode\Encoder\Encoder;
|
||||
use BaconQrCode\Renderer\Color\Rgb;
|
||||
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||
use BaconQrCode\Renderer\ImageRenderer;
|
||||
use BaconQrCode\Renderer\RendererStyle\Fill;
|
||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||
use BaconQrCode\Writer;
|
||||
|
||||
class QrCodeService
|
||||
{
|
||||
/**
|
||||
* The QR code's width and height in the SVG, in pixels: the size a canvas draws it at.
|
||||
*/
|
||||
public const SIZE = 1024;
|
||||
|
||||
/**
|
||||
* Draw the contents as a QR code in SVG: black on white with a four-module quiet zone and
|
||||
* error correction M, the most reliable to scan from a screen or a print. The XML declaration
|
||||
* is dropped so the markup can sit inline in a page.
|
||||
*/
|
||||
public function svg(string $contents): string
|
||||
{
|
||||
$svg = (new Writer(
|
||||
new ImageRenderer(
|
||||
new RendererStyle(self::SIZE, 4, null, null, Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0))),
|
||||
new SvgImageBackEnd,
|
||||
),
|
||||
))->writeString($contents, Encoder::DEFAULT_BYTE_MODE_ENCODING, ErrorCorrectionLevel::M());
|
||||
|
||||
return trim(substr($svg, strpos($svg, "\n") + 1));
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -6,13 +6,18 @@
|
||||
"guidelines": true,
|
||||
"herd_mcp": false,
|
||||
"mcp": true,
|
||||
"packages": [
|
||||
"nonameweb/livewire-material"
|
||||
],
|
||||
"sail": false,
|
||||
"skills": [
|
||||
"infer-conventions",
|
||||
"fortify-development",
|
||||
"laravel-best-practices",
|
||||
"testing-best-practices",
|
||||
"octane-development",
|
||||
"livewire-development",
|
||||
"pest-testing",
|
||||
"tailwindcss-development"
|
||||
"tailwindcss-development",
|
||||
"livewire-material-development"
|
||||
]
|
||||
}
|
||||
|
||||
+17
-4
@@ -10,12 +10,13 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.5",
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"laravel/fortify": "^1.30",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/octane": "^2.13",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/livewire": "^4.0",
|
||||
"robsontenorio/mary": "^2.7"
|
||||
"nonameweb/livewire-material": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
@@ -25,8 +26,9 @@
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^4.3",
|
||||
"pestphp/pest-plugin-laravel": "^4.0"
|
||||
"pestphp/pest": "^5.1",
|
||||
"pestphp/pest-plugin-browser": "^5.0",
|
||||
"pestphp/pest-plugin-laravel": "^5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -82,6 +84,11 @@
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
],
|
||||
"screenshots": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npm run build",
|
||||
"@php -d upload_max_filesize=4G -d post_max_size=4G vendor/bin/pest tests/Screenshots"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
@@ -99,5 +106,11 @@
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
"prefer-stable": true,
|
||||
"repositories": {
|
||||
"livewire-material": {
|
||||
"type": "vcs",
|
||||
"url": "https://gitea.nonameweb.ch/noNameWEB/livewire-material.git"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2753
-1320
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -73,7 +73,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'home' => '/dashboard',
|
||||
'home' => '/admin/dashboard',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Component prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Every component is an anonymous Blade component. Without a prefix they are
|
||||
| <x-button>, <x-card> and so on; set a prefix such as 'm' when a name
|
||||
| clashes with one of the application's own components, and they become
|
||||
| <x-m::button>, <x-m::card>. They are always <x-livewire-material::button>
|
||||
| as well.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => '',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Theme
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The head script decides the theme before the first paint and writes it to
|
||||
| <html data-theme>. 'default' is used until the visitor chooses: 'light',
|
||||
| 'dark' or 'system' (follow the operating system). The choice is kept in
|
||||
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
|
||||
| earlier theme toggle's key) are adopted once and then removed.
|
||||
|
|
||||
*/
|
||||
|
||||
'theme' => [
|
||||
'default' => 'system',
|
||||
'storage_key' => 'sealshare-theme',
|
||||
'legacy_keys' => ['mary-theme'],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Navigation rail
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Whether a collapsible navigation rail starts 'expanded' or 'collapsed'
|
||||
| until the visitor toggles it. The head script applies the choice before
|
||||
| the first paint, from localStorage under 'storage_key'.
|
||||
|
|
||||
*/
|
||||
|
||||
'rail' => [
|
||||
'default' => 'expanded',
|
||||
'storage_key' => 'material-rail',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fields
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Text fields, selects and pickers come in M3's two styles: 'outlined' (a
|
||||
| notched outline) and 'filled' (a tinted box with an indicator line). This
|
||||
| is the style a field takes when its `variant` is not given.
|
||||
|
|
||||
*/
|
||||
|
||||
'fields' => [
|
||||
'variant' => 'outlined',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Pagination
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Draw Laravel's and Livewire's paginators in M3: the package's views are
|
||||
| put in front of `pagination::tailwind` and `livewire::tailwind` (and
|
||||
| their simple versions). An application's own published pagination views
|
||||
| still win.
|
||||
|
|
||||
*/
|
||||
|
||||
'pagination' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Node
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `php artisan material:scheme` runs Google's colour utilities through Node.
|
||||
| Set the binary when `node` is not on the PATH of the user running Artisan.
|
||||
|
|
||||
*/
|
||||
|
||||
'node' => env('MATERIAL_NODE', 'node'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scheme data
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The light and dark hexes `php artisan material:scheme` writes beside the
|
||||
| stylesheet. The mail theme reads its colours here, and so does an error
|
||||
| page when the build is missing; without the file both use the package's
|
||||
| default scheme.
|
||||
|
|
||||
*/
|
||||
|
||||
'scheme' => resource_path('css/material-scheme.json'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Colour profiles
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The profiles an admin chooses between in Admin settings. Each one is a
|
||||
| 'label', a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'.
|
||||
| `php artisan material:scheme` (without a seed) generates them all into
|
||||
| resources/css/material-scheme.css; regenerate after changing this list.
|
||||
| 'profile' is the default, until an admin chooses.
|
||||
|
|
||||
*/
|
||||
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||
'blue' => ['label' => 'Blue', 'seed' => '#0b57d0', 'variant' => 'vibrant'],
|
||||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
'green' => ['label' => 'Green', 'seed' => '#2e7d32', 'variant' => 'vibrant'],
|
||||
'amber' => ['label' => 'Amber', 'seed' => '#e8710a', 'variant' => 'vibrant'],
|
||||
'rose' => ['label' => 'Rose', 'seed' => '#c2185b', 'variant' => 'vibrant'],
|
||||
'violet' => ['label' => 'Violet', 'seed' => '#6750a4', 'variant' => 'vibrant'],
|
||||
'graphite' => ['label' => 'Graphite', 'seed' => '#5f6368', 'variant' => 'neutral'],
|
||||
],
|
||||
|
||||
'profile' => 'indigo',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mail
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Markdown mail takes the theme when `mail.markdown.theme` (MAIL_MARKDOWN_THEME)
|
||||
| is 'livewire-material::mail.theme'. 'components' puts this package's mail
|
||||
| header and message after the application's own mail components — or
|
||||
| publish them with `vendor:publish --tag=livewire-material-mail` instead.
|
||||
| 'logo' replaces the app name in that header with an image: an absolute
|
||||
| 'src', with 'width' and 'height' in pixels, which Outlook sizes it by.
|
||||
|
|
||||
*/
|
||||
|
||||
'mail' => [
|
||||
'components' => (bool) env('MATERIAL_MAIL_COMPONENTS', false),
|
||||
'logo' => [
|
||||
'src' => null,
|
||||
'width' => null,
|
||||
'height' => null,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Showcase
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Every component in every variant, rendered in the application's own
|
||||
| scheme. Off unless the application runs locally. 'vite' names the entry
|
||||
| points that import this package's CSS and JavaScript; the error pages
|
||||
| load them too, showcase or not.
|
||||
|
|
||||
*/
|
||||
|
||||
'showcase' => [
|
||||
'enabled' => (bool) env('MATERIAL_SHOWCASE', env('APP_ENV', 'production') === 'local'),
|
||||
'path' => 'material',
|
||||
'middleware' => ['web'],
|
||||
'vite' => ['resources/css/app.css', 'resources/js/app.js'],
|
||||
],
|
||||
|
||||
];
|
||||
+2
-2
@@ -130,7 +130,7 @@ return [
|
||||
|
||||
'temporary_file_upload' => [
|
||||
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
|
||||
'rules' => ['required', 'file', 'max:4194304'], // 4GB — fine-grained limits enforced per-component
|
||||
'rules' => ['required', 'file'], // No size cap: PHP's upload_max_filesize is the hard limit, the admin limit is enforced per-component
|
||||
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
|
||||
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
|
||||
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
|
||||
@@ -138,7 +138,7 @@ return [
|
||||
'mov', 'avi', 'wmv', 'mp3', 'm4a',
|
||||
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
|
||||
],
|
||||
'max_upload_time' => 30, // Max duration (in minutes) before an upload is invalidated...
|
||||
'max_upload_time' => (int) env('LIVEWIRE_MAX_UPLOAD_TIME', 30), // Max duration (in minutes) before an upload is invalidated...
|
||||
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
|
||||
],
|
||||
|
||||
|
||||
@@ -115,4 +115,26 @@ return [
|
||||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Markdown Mail Settings
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Markdown mail wears Livewire Material's theme, coloured from the light
|
||||
| scheme in resources/css/material-scheme.json.
|
||||
|
|
||||
*/
|
||||
|
||||
'markdown' => [
|
||||
'theme' => env('MAIL_MARKDOWN_THEME', 'livewire-material::mail.theme'),
|
||||
|
||||
'paths' => [
|
||||
resource_path('views/vendor/mail'),
|
||||
],
|
||||
|
||||
'extensions' => [
|
||||
// \League\CommonMark\Extension\Strikethrough\StrikethroughExtension::class,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -8,10 +8,16 @@ services:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- .:/app
|
||||
# Its own node_modules: npm installs the build tools' native binaries for Linux here and for
|
||||
# the host's platform there, and a shared folder only ever holds one of them.
|
||||
- /app/node_modules
|
||||
environment:
|
||||
APP_KEY: ${APP_KEY:-}
|
||||
APP_URL: http://localhost:8000
|
||||
APP_ENV: local
|
||||
# Compiled views stay in the container. The host shares storage/ through the mount, and
|
||||
# compiled Livewire components hold absolute paths (/app/… here, the checkout's path there).
|
||||
VIEW_COMPILED_PATH: /tmp/views
|
||||
APP_DEBUG: "true"
|
||||
SERVER_NAME: ":8000"
|
||||
DB_CONNECTION: sqlite
|
||||
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
# SealShare Application (FrankenPHP/Octane)
|
||||
# ------------------------------------------
|
||||
app:
|
||||
image: ghcr.io/surtic86/sealshare:latest
|
||||
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80" # HTTP
|
||||
@@ -59,6 +59,7 @@ services:
|
||||
# PHP_MAX_EXECUTION_TIME: "300" # Upload timeout in seconds
|
||||
# PHP_MAX_INPUT_TIME: "300" # Input processing timeout
|
||||
# PHP_MEMORY_LIMIT: "512M" # PHP memory limit
|
||||
# LIVEWIRE_MAX_UPLOAD_TIME: "30" # Minutes a single upload may take (raise for large files on slow links)
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--silent", "--fail", "http://localhost/up"]
|
||||
interval: 30s
|
||||
@@ -70,7 +71,7 @@ services:
|
||||
# Scheduler - Runs cleanup for expired shares
|
||||
# ------------------------------------------
|
||||
scheduler:
|
||||
image: ghcr.io/surtic86/sealshare:latest
|
||||
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
|
||||
restart: unless-stopped
|
||||
entrypoint: ["php", "artisan", "schedule:work"]
|
||||
volumes:
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/surtic86/sealshare:latest
|
||||
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -38,6 +38,7 @@ services:
|
||||
PHP_MAX_EXECUTION_TIME: ${PHP_MAX_EXECUTION_TIME:-300}
|
||||
PHP_MAX_INPUT_TIME: ${PHP_MAX_INPUT_TIME:-300}
|
||||
PHP_MEMORY_LIMIT: ${PHP_MEMORY_LIMIT:-512M}
|
||||
LIVEWIRE_MAX_UPLOAD_TIME: ${LIVEWIRE_MAX_UPLOAD_TIME:-30}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--silent", "--fail", "http://localhost/up"]
|
||||
interval: 30s
|
||||
@@ -46,7 +47,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
scheduler:
|
||||
image: ghcr.io/surtic86/sealshare:latest
|
||||
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -13,6 +13,11 @@ max_input_time = ${PHP_MAX_INPUT_TIME:-300}
|
||||
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
||||
EOF
|
||||
|
||||
if [ ! -f vendor/autoload.php ]; then
|
||||
echo "[dev] Installing PHP dependencies..."
|
||||
composer install --no-interaction 2>&1
|
||||
fi
|
||||
|
||||
echo "[dev] Installing Node dependencies..."
|
||||
npm install 2>&1
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ RUN install-php-extensions \
|
||||
# Install Node.js for Vite / frontend asset building
|
||||
RUN apk add --no-cache nodejs npm
|
||||
|
||||
# Composer, for a checkout without vendor/: the assets import Livewire Material from it
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY docker/dev-entrypoint.sh /usr/local/bin/dev-entrypoint.sh
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# Colour profiles
|
||||
|
||||
## Goal
|
||||
|
||||
An installation of SealShare can wear one of eight colour profiles instead of the single indigo
|
||||
scheme. The admin picks the profile in Admin settings, previews it on the page while choosing, and
|
||||
on Save it applies to everyone: signed-in users, recipients on the upload and download pages, the
|
||||
Markdown mails and the error pages. Livewire Material learns colour profiles in general — any
|
||||
application lists its own in config, the package generates them, switches between them before the
|
||||
first paint and follows the active one everywhere it draws colour — and ships it as 1.1.0 before
|
||||
SealShare tags 2.0.0.
|
||||
|
||||
## Context
|
||||
|
||||
**Livewire Material 1.0.1** (`../livewire-material`):
|
||||
|
||||
- `php artisan material:scheme {seed} --variant= --contrast= --success= --warning= --info= --output=`
|
||||
(`src/Console/SchemeCommand.php`) runs `resources/node/scheme.mjs` (Google's
|
||||
material-color-utilities, a 93 KB bundle) through Node and writes `resources/css/material-scheme.css`
|
||||
— `:root, [data-theme='light'] { color-scheme: light; --md-sys-color-*: … }` and
|
||||
`[data-theme='dark'] { … }`, about 60 roles each — and `material-scheme.json`
|
||||
(`{seed, variant, spec, contrast, light, dark}`).
|
||||
- Every component and token reads only `--md-sys-color-*` (`resources/css/tokens/theme.css` maps them
|
||||
to Tailwind colours). The package's own default is `resources/css/tokens/scheme.css` and `.json`.
|
||||
- `<x-theme-script>` (in `<head>`, before `@vite`) writes `data-theme`, `data-theme-choice`,
|
||||
`data-theme-key`, `data-rail`, `data-rail-key` on `<html>` before the first paint, and puts them
|
||||
back after a `wire:navigate` swap (`onSwap`). `$store.theme` lives in `resources/js/theme.js`.
|
||||
- `Support\Scheme::load()` / `light()` read `config('livewire-material.scheme')` (the JSON) merged over
|
||||
the package default; the mail theme (`resources/views/mail/theme.blade.php`) and the fallback styles
|
||||
of the error pages (`Support\ErrorPage::fallbackStyles()`) use it.
|
||||
- Tests: `SchemeCommandTest` (runs Node), `TokensTest`, `MailThemeTest`, `ErrorPagesTest`,
|
||||
`ShowcaseTest`, browser tests in three engines; CI on Gitea.
|
||||
|
||||
**SealShare:**
|
||||
|
||||
- One scheme, `#4f46e5` Vibrant (`.ai/rules/css.md` records the exact command).
|
||||
- The production Docker image has no Node, so nothing can be generated at runtime.
|
||||
- Settings → Appearance is the Light/Dark/System picker, stored per browser. All users are admins;
|
||||
recipients are guests.
|
||||
- `App\Livewire\Admin\AdminSettings` holds the settings in `Setting` (key/value) and saves them in
|
||||
`saveSettings()` with one validation call; the form ends in "Save Settings". It uses `Toasts`.
|
||||
- Octane: the application boots once per worker, so anything request-specific must be read per call.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **The admin chooses, nobody else** — one profile for the whole installation; no per-user or
|
||||
per-visitor choice. Light, dark and system stay each visitor's own, as now.
|
||||
- **Ready-made profiles, no free colour** — generated ahead of time with `material:scheme` and shipped
|
||||
in the CSS: correct from the first frame, no generator in the browser, mails and error pages can
|
||||
follow.
|
||||
- **Eight profiles, Vibrant style like today** — `indigo` Indigo `#4f46e5` (the default, today's),
|
||||
`blue` Blue `#0b57d0`, `teal` Teal `#00897b`, `green` Green `#2e7d32`, `amber` Amber `#e8710a`,
|
||||
`rose` Rose `#c2185b`, `violet` Violet `#6750a4`, all `vibrant`; `graphite` Graphite `#5f6368`
|
||||
in the `neutral` style.
|
||||
- **The mechanism is the package's, the profiles are the application's** — Livewire Material gets
|
||||
`profiles` in its config; SealShare lists its eight in its published config. Other applications
|
||||
define their own.
|
||||
- **Swatch picker at the top of Admin settings, previewed live, applied on Save** — a "Colour
|
||||
profile" card with one swatch per profile (primary, secondary and tertiary dots, the name, a check
|
||||
on the chosen one); a click recolours the page at once; "Save Settings" stores it for everyone.
|
||||
Leaving without saving shows the saved profile on the next page.
|
||||
- **Profiles are keyed by `<html data-scheme>`** — the default profile also stands without the
|
||||
attribute, so the stylesheet works before the head script runs and with an unknown name:
|
||||
|
||||
```css
|
||||
:root, [data-theme='light'] { /* default, light */ }
|
||||
[data-theme='dark'] { /* default, dark */ }
|
||||
[data-scheme='teal'], [data-scheme='teal'][data-theme='light'] { /* teal, light */ }
|
||||
[data-scheme='teal'][data-theme='dark'] { /* teal, dark */ }
|
||||
```
|
||||
|
||||
A profile's two-attribute selectors outrank the default's single ones, and its one-attribute
|
||||
selector comes later in the file than `:root`, so the order is part of the format.
|
||||
- **The active profile is resolved on every use, never kept** — the application registers a resolver
|
||||
once (`Scheme::resolveProfileUsing(fn (): ?string => …)`); the head script, the mail theme and the
|
||||
error pages call it each time they draw. A name that is not a generated profile, or no resolver,
|
||||
falls back to the JSON's `default` — the `profile` config (else the first profile) when the scheme
|
||||
was generated. Nothing request-specific is
|
||||
stored on a static, so Octane workers stay clean.
|
||||
- **The JSON keeps its old top-level shape** — `light` and `dark` are still the default profile's
|
||||
roles, beside `default` and `profiles.{name}.{label, seed, variant, spec, contrast, light, dark}`,
|
||||
so a reader of the 1.0 format keeps working.
|
||||
- **`material:scheme` with a seed is unchanged** — one scheme, as in 1.0. Without a seed it generates
|
||||
every configured profile; without either it fails with a message naming both ways.
|
||||
- **A `<x-scheme-picker>` component in the package** — native radios in a `radiogroup`, bound with
|
||||
`wire:model` (or `x-model`), each labelled with the profile's name and its three colours from the
|
||||
JSON; choosing one sets `<html data-scheme>` immediately (the preview). Errors for the bound property
|
||||
show under it.
|
||||
- **The showcase can preview every profile** — a profile menu in its app bar when profiles are
|
||||
configured, recolouring the showcase without storing anything.
|
||||
- **Release** — Livewire Material 1.1.0 (a feature), then SealShare's lock, all before 2.0.0.
|
||||
SealShare's changelog lists it under 2.0.0 "Added".
|
||||
|
||||
## Out of scope
|
||||
|
||||
- A colour picker for any colour, extracting a colour from the logo, or per-profile contrast levels.
|
||||
- Per-user or per-visitor profiles, or a profile switch outside Admin settings.
|
||||
- Changing the website's colours (it stays indigo) or adding profile screenshots.
|
||||
- New success/warning/info sources per profile — they stay the package defaults.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
### Livewire Material 1.1.0 (`../livewire-material`)
|
||||
|
||||
1. **Config.** `config/livewire-material.php`: `'profiles' => []` (name ⇒ `label`, `seed`,
|
||||
`variant`, optional `contrast`) and `'profile' => null` (the fallback name), documented in the
|
||||
config comment beside `scheme`.
|
||||
2. **Generator.** `SchemeCommand`: `seed` becomes optional. Without it, read `profiles`; for each
|
||||
run `scheme.mjs` as today (validating seed, variant and contrast through the generator's own
|
||||
errors), then write the stylesheet in the format under Decisions — the default profile (the
|
||||
`profile` config, else the first) as the plain blocks, then every profile's blocks in config
|
||||
order — and the JSON with `default`, `profiles` and the default's top-level `light`/`dark`. The
|
||||
header comment names the command and says the profiles come from config. With neither a seed nor
|
||||
profiles, fail naming both.
|
||||
3. **Scheme.** `Support\Scheme`: `resolveProfileUsing(?Closure $resolver): void`,
|
||||
`profiles(?string $path = null): array` (name ⇒ label and light/dark roles, from the JSON),
|
||||
`profile(?string $path = null): ?string` (the resolver's answer if it names a profile in the
|
||||
JSON, else `default` from the JSON, else null), and `load(?string $path = null, ?string $profile = null)`
|
||||
returning that profile's roles merged over the package default (the active profile when
|
||||
`$profile` is null; the top-level roles for a 1.0 file). `light()` follows, so the mail theme and
|
||||
`ErrorPage::fallbackStyles()` draw the active profile without further change: the fallback's plain
|
||||
`:root`/`[data-theme]` blocks carry that profile's roles, which is all a page without its build
|
||||
needs. Every method reads the JSON on each call, as `load()` does today.
|
||||
4. **Head script.** `<x-theme-script>`: when the JSON has profiles, write
|
||||
`data-scheme="{active profile}"` on `<html>` with the others, and keep it through `onSwap`.
|
||||
`$store.theme` gains `scheme` (read from the attribute) and `previewScheme(name)` (sets the
|
||||
attribute, stores nothing).
|
||||
5. **Picker.** `resources/views/components/scheme-picker.blade.php` as under Decisions: props
|
||||
`label`, `hint`, `profiles` (default `Scheme::profiles()`), `name`; labels through `__()`. Each
|
||||
swatch is a label around a visually hidden native radio, drawn with Tailwind utilities (a
|
||||
`surface-container` tile, `outline` when checked, a check icon); its three dots are the only inline
|
||||
styles — `background-color` from that profile's light roles, which `Scheme` has already checked
|
||||
are `#rrggbb` — because they show another profile's colours than the page's. `x-on:change` calls
|
||||
`$store.theme.previewScheme($event.target.value)`. With no profiles it renders nothing.
|
||||
6. **Showcase.** A profile menu in `resources/views/showcase/layout.blade.php`'s app bar when profiles
|
||||
exist, calling `previewScheme`; the colour section already reads the variables, so it follows.
|
||||
`src/Showcase/Sections.php` gains the picker as an example (and the search index with it).
|
||||
7. **Docs.** `resources/boost/skills/livewire-material-development/SKILL.md` (Colour scheme: profiles,
|
||||
resolver, picker; the new component in Components), `resources/boost/guidelines/core.blade.php`
|
||||
(one line), `README.md` (Colour scheme and Configuration).
|
||||
8. **Release.** Verify in `.verify` (Feature + Browser in chrome, firefox, safari), push, watch CI,
|
||||
tag `1.1.0`.
|
||||
|
||||
### SealShare
|
||||
|
||||
9. **Package.** `composer update nonameweb/livewire-material` to 1.1.0.
|
||||
10. **Profiles.** `config/livewire-material.php`: the eight profiles under Decisions and
|
||||
`'profile' => 'indigo'`. Run `php artisan material:scheme` to regenerate
|
||||
`resources/css/material-scheme.css` and `.json`; `npm run build`. Update `.ai/rules/css.md`: the
|
||||
scheme is regenerated with `php artisan material:scheme` from the profiles in config, never
|
||||
hand-edited.
|
||||
11. **Resolver.** `AppServiceProvider::boot()`: `Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'))`.
|
||||
12. **Admin settings.** `AdminSettings`: `public string $colorProfile`, mounted from
|
||||
`Scheme::profile()`; validated with `Rule::in(array_keys(Scheme::profiles()))` in
|
||||
`saveSettings()` — the profiles actually generated into the stylesheet, not merely listed in
|
||||
config; saved with `Setting::set('color_profile', $this->colorProfile)`.
|
||||
`admin-settings.blade.php`: a "Colour profile" card first in the form with
|
||||
`<x-scheme-picker wire:model="colorProfile" :label="__('Colour profile')" />` and a hint that
|
||||
the choice applies to every page, mail and error page after saving.
|
||||
13. **Docs.** README features: "Colour Profiles — eight colour profiles, chosen by the admin".
|
||||
CHANGELOG `2.0.0` "Added". `composer screenshots` again (the admin settings shot shows the new
|
||||
card); the website's feature list gains the same line.
|
||||
|
||||
## Testing
|
||||
|
||||
**Package**
|
||||
|
||||
- `SchemeCommandTest`: with profiles configured and no seed, the JSON has `default` and every profile
|
||||
with light and dark roles, top-level `light`/`dark` equal the default's; the stylesheet has the
|
||||
default's plain blocks first and each profile's `[data-scheme='…']` blocks after, with its hexes;
|
||||
a seed still writes the 1.0 format; neither fails with the message.
|
||||
- `SchemeTest` (new, Feature): `profile()` follows a resolver naming a profile, falls back on an
|
||||
unknown name, on no resolver and on a 1.0 file; `load()` returns the chosen profile's roles.
|
||||
- `MailThemeTest`: the mail's primary is the resolved profile's. `ErrorPagesTest`: the fallback
|
||||
styles carry the resolved profile's roles.
|
||||
- Components: `<x-theme-script>` renders `data-scheme` for the resolved profile and none without
|
||||
profiles; `<x-scheme-picker>` renders a radio per profile, checked from `wire:model`, with the
|
||||
labels.
|
||||
- Browser (three engines): `--md-sys-color-primary` on `<html>` is the profile's in light and in
|
||||
dark, and the default's without the attribute; choosing a swatch changes it at once; the attribute
|
||||
survives `wire:navigate`; the showcase menu previews a profile.
|
||||
|
||||
**SealShare**
|
||||
|
||||
- `AdminSettingsTest`: a valid profile is saved and a toast dispatched; an unknown one fails
|
||||
validation and saves nothing.
|
||||
- `ColourProfileTest` (new, Feature): a guest's upload page renders `data-scheme` from the saved
|
||||
setting and the default without one; the reset-password mail uses the profile's primary.
|
||||
- `tests/Browser/SealShareTest.php`: in Admin settings a swatch recolours the page before saving;
|
||||
after Save and a reload, and on a guest's download page, the profile stays.
|
||||
- `DesignLanguageTest` and `WebsiteTest` keep passing.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Stylesheet size.** Eight profiles × two themes × ~60 roles is about 60 KB before compression
|
||||
(a few KB gzipped); acceptable, and the CSS stays cacheable.
|
||||
- **A query per page for the setting.** `Setting::get('color_profile')` runs when the head script
|
||||
renders, like the site title already does; cache it later if it ever shows.
|
||||
- **Swatch colours are inline styles.** The design guard does not look at `style` attributes, so
|
||||
nothing stops them spreading; they stay inside `<x-scheme-picker>` and come only from `Scheme`'s
|
||||
checked hexes, which the component test asserts.
|
||||
- **A profile removed from config** while saved leaves the setting pointing nowhere; the resolver's
|
||||
fallback to the default covers it, and Admin settings shows the default as chosen.
|
||||
- **Config and stylesheet out of step.** A profile added to config but not generated is not offered:
|
||||
the picker, the resolver and the validation all read the generated JSON. `.ai/rules/css.md` says to
|
||||
regenerate after changing profiles.
|
||||
- **Open tabs** keep the profile they loaded (or previewed) until their next full load;
|
||||
`wire:navigate` carries the page's current attribute forward.
|
||||
- **Error pages without a build** use the fallback styles, which draw the active profile directly;
|
||||
covered by `ErrorPagesTest`.
|
||||
@@ -0,0 +1,223 @@
|
||||
# SealShare on Livewire Material (2.0.0)
|
||||
|
||||
> The package itself — its decisions, the wave plan (Phases 1–10) and its tests — moved to
|
||||
> the package repo on 2026-09-13: [noNameWEB/livewire-material · docs/plans/livewire-material.md](https://gitea.nonameweb.ch/noNameWEB/livewire-material/src/branch/main/docs/plans/livewire-material.md).
|
||||
> This file keeps what SealShare does once the package reaches `1.0.0`.
|
||||
|
||||
## Goal
|
||||
|
||||
SealShare's UI is maryUI 2.9 on daisyUI 5 — a generic web-page look. After this change it runs
|
||||
on **`nonameweb/livewire-material` `^1.0`**: a clean, calm indigo Material 3 Expressive app with
|
||||
a top app bar, light / dark / system theme, and two Expressive moments — the upload drop zone
|
||||
and "link ready" — shipped as SealShare 2.0.0.
|
||||
|
||||
## Context
|
||||
|
||||
**Stacks.** SealShare: Laravel 13.31, Livewire 4.4, maryUI 2.9.10 (no prefix), daisyUI 5.7,
|
||||
Tailwind 4.3, Pest 5.1, Octane on FrankenPHP, PHP 8.5; public on GitHub under MIT, image
|
||||
published to `ghcr.io/surtic86/sealshare`. ReStride: same Laravel / Livewire / Tailwind / Pest,
|
||||
private on `gitea.nonameweb.ch`, CI through Gitea act_runner.
|
||||
|
||||
**SealShare's UI surface** (inventory, 2026-09-13):
|
||||
|
||||
- maryUI tags: `button` 30, `input` 18, `password` 16, `icon` 14, `card` 9 (6 `actions`
|
||||
slots), `menu`/`menu-item` 1/4 (settings nav), `theme-toggle` 3, `toggle` 2, `select` 2,
|
||||
`modal` 2, `table` 1 (`:headers :rows :sort-by with-pagination`, `@scope`), `textarea` 1,
|
||||
`toast` 1 (never triggered).
|
||||
- Raw daisyUI: `btn` (+ `-primary/-ghost/-sm/-xs/-error/-outline/-disabled`), `alert` ×6,
|
||||
`card`/`card-body` (4 admin stat tiles), `join` (2 copy fields), `progress` ×2,
|
||||
`loading` ×2, `badge-success/-error`, `divider`, `link link-primary` ×5, `label`,
|
||||
`file-input`, `checkbox`; tokens `bg-base-*`, `border-base-300`, `text-error/success`,
|
||||
`border-primary(/50)`, `bg-primary/5`; raw `text-green-600`, `bg-white` (QR code).
|
||||
Secondary text is `opacity-50/60/70`.
|
||||
- 20 Heroicons (outline), through `blade-heroicons` pulled in transitively by maryUI.
|
||||
- No `Mary\` PHP coupling. Admin settings flashes `session('message')` into an alert
|
||||
(`AdminSettings.php:116,128,135`). 3 `wire:confirm`.
|
||||
- Layouts: `layouts/app` → `app/sidebar` (centered `max-w-5xl` + footer nav), used by the
|
||||
Livewire pages and all settings SFCs (`config/livewire.php:47`); `layouts/auth` →
|
||||
`auth/simple`. The theme script sits *outside* `<head>` and hard-codes dark, while maryUI's
|
||||
toggle defaults from the OS. `partials/head` loads Instrument Sans from fonts.bunny.net.
|
||||
- Dead: `/dashboard` (starter placeholder, and Fortify's `home`), `welcome`,
|
||||
`pages/auth/register` (still referenced by `Fortify::registerView`,
|
||||
`FortifyServiceProvider.php:52`), `layouts/app/header`, `layouts/auth/{card,split}`,
|
||||
`components/app-logo`, `components/desktop-user-menu`, `components/placeholder-pattern`;
|
||||
the `alpinejs` npm dependency; the Flux credentials step in `tests.yml` and `docker.yml`.
|
||||
- Settings `profile` and `password` show "Saved." through `components/action-message`,
|
||||
listening for `profile-updated` / `password-updated`; `partials/settings-heading` uses a
|
||||
daisyUI `divider`. The 3 `wire:confirm` are admin settings (remove logo, clear system
|
||||
password) and admin dashboard (delete share). `AdminDashboard::headers()` exists only for
|
||||
maryUI's table.
|
||||
- Tests assert text only, never markup; no browser tests.
|
||||
- Docker: the image's caches run in `docker/entrypoint.sh` (`config:cache`, `route:cache`,
|
||||
`view:cache`); `docker/dev-entrypoint.sh` runs `npm run build` against the host's mounted
|
||||
`vendor/` without a `composer install`. The Flux credentials step is in `tests.yml`,
|
||||
`docker.yml` **and** `lint.yml`.
|
||||
- Screens: setup, system password, upload, share created, share download, admin dashboard,
|
||||
admin settings, settings (profile, password, appearance, two-factor), Fortify pages (login,
|
||||
forgot, reset, 2FA challenge, confirm, verify email). Stock Laravel error pages and mails.
|
||||
|
||||
**Constraints found.**
|
||||
|
||||
- SealShare's `Dockerfile` builds assets (stage 1) **before** `composer install` (stage 2);
|
||||
CSS imported from `vendor/` needs the order swapped.
|
||||
- Laravel **replaces** the `errors` view namespace at render time with
|
||||
`config('view.paths')` + `/errors` and the framework's own
|
||||
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
|
||||
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
|
||||
- The package lives at `https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public,
|
||||
anonymous reads verified 2026-09-13).
|
||||
|
||||
## Decisions
|
||||
|
||||
The package's decisions are in its own plan. SealShare's:
|
||||
|
||||
- **Converts after `1.0.0`, in one pass, by hand** (~150 tags; no codemod), on branch
|
||||
`material`, released as **2.0.0**.
|
||||
- **Moving SealShare to Gitea is a separate plan** — this plan works wherever it is hosted.
|
||||
- **Seed `#4f46e5` (the favicon's indigo), Vibrant** — chosen after comparing it with Tonal Spot
|
||||
on the upload page in both themes (2026-09-13): Tonal Spot read grey-lavender on this seed.
|
||||
- **Theme default `system`**, storage key `sealshare-theme`, legacy `mary-theme` adopted once.
|
||||
Appearance is a Light / Dark / System connected button group.
|
||||
- **One top app bar everywhere** — logo and site title; a theme toggle for guests, an avatar
|
||||
account menu (Upload, Admin dashboard, Admin settings, Settings, theme, Log out) for users;
|
||||
centered content; Admin and Settings sub-pages as secondary tabs (menu picker on a phone);
|
||||
auth pages a centered card under the same bar. No rail, no bottom bar.
|
||||
- **Expressive components plus two hero moments** — an Expressive shape behind the upload icon
|
||||
that morphs while files are dragged over, the wavy progress indicator for uploads, a
|
||||
shape-backed check when the link is ready; admin stats count up once. Instant under
|
||||
`prefers-reduced-motion`.
|
||||
- **The public download page uses no anchored components** (no menus, no tooltips) — it must
|
||||
work for recipients on iOS below 18.4.
|
||||
- **Starter-kit cleanup during the conversion** — delete the placeholder `/dashboard`, point
|
||||
Fortify `home` at the admin dashboard, delete the unused views and the `registerView`
|
||||
binding, drop `alpinejs` from npm and the Flux step from CI.
|
||||
- **Confirmations become M3 basic dialogs** (the 3 `wire:confirm`) — the browser's native
|
||||
confirm cannot be themed and reads as a different app. *(Not asked in the interview; object
|
||||
in review if you prefer the native confirm.)*
|
||||
- **Save feedback becomes a snackbar** through the package's `Toasts` concern — admin
|
||||
settings' flashed `session('message')` alert and settings' "Saved." `action-message` alike.
|
||||
*(Follows from the snackbar; not asked separately.)*
|
||||
- **The font is self-hosted** — the fonts.bunny.net request goes, which also suits a
|
||||
privacy-minded self-hosted app.
|
||||
- **Tests: updated feature tests, the package's guard as `DesignLanguageTest`, Livewire tests
|
||||
for changed behaviour, and four browser tests** with `pestphp/pest-plugin-browser` (new dev
|
||||
dependency, approved).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- ReStride adopting the package — its own plan, after `1.0.0`.
|
||||
- Moving SealShare's repository, CI and image registry to Gitea — its own plan.
|
||||
- Everything the package plan puts out of scope.
|
||||
- Changes to SealShare's features, routes or information architecture beyond the cleanup above.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
Step numbers continue the original plan's, so references elsewhere stay valid.
|
||||
|
||||
### Phase 11 — SealShare 2.0.0 (after `1.0.0`)
|
||||
|
||||
34. **Branch** `material` from `main`; open the PR so CI runs.
|
||||
35. **Dependencies.** Add the `vcs` repository and `composer require nonameweb/livewire-material:^1.0`;
|
||||
`composer remove robsontenorio/mary` (drops `blade-heroicons` with it);
|
||||
`npm remove daisyui alpinejs`; `composer require --dev pestphp/pest-plugin-browser`.
|
||||
maryUI goes **first** because its class components would shadow the package's same-named
|
||||
anonymous ones; the branch is therefore red from here until step 44, which is accepted —
|
||||
it merges once, green (Decisions: one pass).
|
||||
36. **CI and Docker.** Remove the Flux credentials step from `.github/workflows/tests.yml`,
|
||||
`docker.yml` and `lint.yml`; install Playwright browsers in `tests.yml`. `Dockerfile`: run
|
||||
the Composer stage first and `COPY --from=vendor /app/vendor ./vendor` into the Node stage
|
||||
before `npm run build`. `docker/dev-entrypoint.sh`: run `composer install` when `vendor/` is
|
||||
missing, before `npm run build`. No `icons:cache` anywhere: the package draws its symbols
|
||||
without blade-icons.
|
||||
37. **Styles and scheme.** `resources/css/app.css`: `@import 'tailwindcss'`, the package entry
|
||||
from `vendor/`, `./material-scheme.css`, `@source '../views'` and the package's views; drop
|
||||
the daisyUI plugin, maryUI and pagination `@source`s and the swap safelist.
|
||||
`resources/js/app.js` imports the package JS. Run
|
||||
`php artisan material:scheme "#4f46e5" --variant=vibrant` (chosen over Tonal Spot after
|
||||
comparing both on the upload page in both themes).
|
||||
38. **Head and theme.** `partials/head`: remove fonts.bunny.net; include `<x-theme-script />`
|
||||
before `@vite` (it currently sits outside `<head>`). Publish the config with
|
||||
`theme.default = system`, `storage_key = sealshare-theme`, `legacy_keys = ['mary-theme']`.
|
||||
39. **Layouts.** Rebuild `layouts/app.blade.php` (absorbing `app/sidebar`): `<x-app-bar>` with
|
||||
`app-logo-icon` / branding logo and site title, `<x-theme-toggle>` for guests or
|
||||
`<x-account-menu>` for users (Upload, Admin dashboard, Admin settings, Settings, theme, Log
|
||||
out through `App\Livewire\Actions\Logout`), centered content, `<x-toast>`.
|
||||
`layouts/auth.blade.php` (absorbing `auth/simple`): the same bar and a centered card.
|
||||
40. **Cleanup.** Delete the `/dashboard` route, `dashboard.blade.php`, `placeholder-pattern`,
|
||||
`welcome`, `pages/auth/register` and its `Fortify::registerView` line,
|
||||
`layouts/app/{header,sidebar}`, `layouts/auth/{card,split,simple}`, `app-logo`,
|
||||
`desktop-user-menu`. Fortify `home` → `/admin/dashboard`. Update `AuthenticationTest:22`
|
||||
and `EmailVerificationTest:32,63` to the new redirect; `DashboardTest` is rewritten to
|
||||
assert that a signed-in admin lands on the admin dashboard and `/dashboard` is gone
|
||||
(replacing its placeholder tests, approved in the interview). `RegistrationTest` stays.
|
||||
41. **Public pages.** `livewire/file-uploader`: drop zone with `<x-shape>` behind the upload
|
||||
icon morphing while `dragging`, existing Alpine folder walking and `livewire-upload-*`
|
||||
wiring kept, wavy `<x-progress>`, `<x-loading>` for processing, selected files as
|
||||
`<x-list>`, Share Options `<x-card>` (`<x-toggle>`, `<x-select>`, number `<x-input>`s),
|
||||
`<x-alert>` for storage full, filled primary "Create Share Link".
|
||||
`share-created`: shape-backed check, `<x-input copyable>` for the link, four `<x-stat>`,
|
||||
info `<x-alert>`, "Upload More". `share-download`: password `<x-card>` with
|
||||
`<x-password>`, files as `<x-list>` with download icon buttons, "Download All" — no menus
|
||||
or tooltips. `system-password-prompt`, `setup-wizard` onto fields and buttons.
|
||||
42. **Auth pages** (`login`, `forgot-password`, `reset-password`, `two-factor-challenge`,
|
||||
`confirm-password`, `verify-email`): fields, `<x-checkbox>` for remember me, `link` utility
|
||||
for text links, `auth-session-status` onto `<x-alert>` (drops `text-green-600`).
|
||||
43. **Settings.** `pages/settings/layout` → `<x-section-nav>`; `partials/settings-heading`
|
||||
drops the daisyUI divider for `<x-divider>`; `profile` and `password` show "Saved." as a
|
||||
snackbar through `Toasts` (the `profile-updated` / `password-updated` dispatches stay for
|
||||
any listener) and `components/action-message` is deleted;
|
||||
`appearance` → Light / Dark / System `<x-group>` on `$store.theme` (the only toggle on the
|
||||
page); `two-factor` → `<x-badge>` status, `<x-modal fullscreen>` setup with the QR on a
|
||||
white token surface, `<x-input copyable>` key, recovery codes; `delete-user-form` →
|
||||
`<x-modal>` with a `danger` action.
|
||||
44. **Admin.** `admin-dashboard`: four `<x-stat>` (counting up once), disk usage
|
||||
`<x-progress>`, hand-written `<x-table>` with `<x-sort-header>` and pagination (the
|
||||
`@scope` cells become plain Blade and `AdminDashboard::headers()` goes), view and delete
|
||||
icon buttons, delete confirmation in a basic `<x-modal>` instead of `wire:confirm`.
|
||||
`admin-settings`: cards, `<x-textarea>`, `<x-file>` for the logo with preview,
|
||||
`<x-toggle>`, `<x-select>`, `<x-input suffix>`; "Remove the logo?" and "Remove the system
|
||||
password?" become basic dialogs instead of `wire:confirm`; `AdminSettings` uses `Toasts`
|
||||
instead of `session()->flash('message')` (3 places) and the alert block goes. Keep every
|
||||
existing `data-test` attribute on the element that now plays its role.
|
||||
45. **Error pages and mail.** Confirm the package's error views render in SealShare's theme;
|
||||
set `config/mail.php` `markdown.theme` to `livewire-material::mail.theme`; check the
|
||||
password-reset and verify-email mails.
|
||||
46. **Guards.** `tests/Feature/DesignLanguageTest.php` using `DesignGuard` over
|
||||
`resources/views` and `app/` — no maryUI, no daisyUI, only declared colours, only existing
|
||||
icons. A grep for `base-content|bg-base|btn|mary` returns nothing.
|
||||
47. **Rules and AI.** `php artisan boost:update --discover` to install the package guideline and
|
||||
skill; `record-rule` for SealShare: the scheme is regenerated with `material:scheme`,
|
||||
never hand-edited; the download page stays free of anchored components; the theme key.
|
||||
48. **Docs.** README tech stack and the "Dark Mode" feature line; CHANGELOG `2.0.0`.
|
||||
49. **Ship.** Full suite green on the PR; merge; tag `v2.0.0` (publishes the image through
|
||||
`docker.yml`).
|
||||
|
||||
## Testing
|
||||
|
||||
- Feature tests updated where redirects or text change: `AuthenticationTest`,
|
||||
`EmailVerificationTest`, `DashboardTest` (rewritten), `AdminSettingsTest` (asserts the
|
||||
toast is dispatched instead of the flash), `TwoFactorAuthenticationTest`,
|
||||
`AdminDashboardTest`, `ShareDownloadTest`.
|
||||
- `DesignLanguageTest` through the package guard.
|
||||
- Livewire tests: admin settings save/remove-logo/clear-password dispatch toasts; profile and
|
||||
password updates dispatch the "Saved." toast (`ProfileUpdateTest`, `PasswordUpdateTest`);
|
||||
delete share, remove logo and clear system password go through their dialogs' confirm
|
||||
actions.
|
||||
- Browser tests (`tests/Browser`): upload by drop and by Browse → progress → share created →
|
||||
copy link; the password-protected download page at 393px; admin table sort and delete
|
||||
dialog; a first visit follows the OS theme and Appearance switches it.
|
||||
- Narrow runs per step; the full suite on the PR's CI.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Scope and time.** The whole catalogue (~45 components plus extras) comes before SealShare
|
||||
changes at all, so its starter-kit bugs (the placeholder `/dashboard`) stay until then.
|
||||
Mitigation: waves tagged `0.x`, each reviewed in the showcase; SealShare keeps working
|
||||
meanwhile.
|
||||
- **Gitea becomes a build dependency.** Every SealShare CI run and Docker build fetches the
|
||||
package from `gitea.nonameweb.ch`; an outage or a sign-in setting reverting breaks builds.
|
||||
Mitigation: dist archives cached by Composer in CI; revisit Packagist if it bites.
|
||||
- **iOS / Safari below 18.4.** Anchored menus and tooltips do not position there. Mitigation:
|
||||
SealShare's download page uses none; native `<select>` stays the fallback everywhere.
|
||||
- **Scheme and spring values are tuned by eye**; Tonal Spot may read washed out on indigo —
|
||||
the Vibrant comparison in step 37 is the check.
|
||||
@@ -0,0 +1,257 @@
|
||||
# Screenshots and the SealShare website
|
||||
|
||||
## Goal
|
||||
|
||||
Two things that feed each other. First, one command — `composer screenshots` — produces every
|
||||
screenshot of SealShare from fixed demo data, desktop and phone, light and dark, ready for the web.
|
||||
Second, a static website at **sealshare.nonameweb.ch**, made the way mailifysms.nonameweb.ch is:
|
||||
hand-written HTML and CSS in `website/`, uploaded by hand. The site presents SealShare as what it
|
||||
is — software a company installs to run **its own upload platform**, so it exchanges files with
|
||||
customers securely without relying on an outside service — shows the screenshots, compares
|
||||
SealShare with hosted transfer services and with other self-hosted tools, and tells how to install
|
||||
it. It goes live with 2.0.0. The README gets a few of the same screenshots, and its encryption
|
||||
wording is corrected.
|
||||
|
||||
## Context
|
||||
|
||||
**MailifySMS, the model** (`../MailifySMS`):
|
||||
|
||||
- `website/` holds `index.html`, `privacy_policy.html`, `terms_and_conditions.html`,
|
||||
`css/theme.css` (a palette sampled from the app's screenshots), `css/device-frame.css` (a phone
|
||||
bezel shared with the store canvases), self-hosted Poppins (`fonts/`, OFL) and `img/`
|
||||
(`icon.png`, `hero.jpg`, `screenshots/{light,dark}/NN-name.png` at 540px).
|
||||
- `index.html`: sticky nav with a phone toggle, hero, "How it works", "Key features", a screenshot
|
||||
gallery with a Light/Dark switch (`data-light`/`data-dark` on each `<img>`), FAQ accordion,
|
||||
contact card (`surtic86@gmail.com`), footer (quick links, legal). Plausible:
|
||||
`<script defer data-domain="mailifysms.nonameweb.ch" src="https://plausible.io/js/script.js">`.
|
||||
The page's JS is one inline `<script>` at the end.
|
||||
- `CLAUDE.md` records that `website/` "is a faithful copy of what is deployed, images included, so
|
||||
it can be uploaded wholesale". There is no deploy automation.
|
||||
- `tools/screenshots.sh` (macOS only) drives an emulator and headless Chrome; documented in
|
||||
`CLAUDE.md` § Screenshots, with the reasons behind each quirk.
|
||||
|
||||
**Hosting.** `*.nonameweb.ch` is a wildcard DNS record to `80.74.140.2` (METANET shared hosting,
|
||||
nginx), the same as mailifysms. `sealshare.nonameweb.ch` resolves already; HTTP serves the host's
|
||||
placeholder, HTTPS has no certificate. Creating the site and its Let's Encrypt certificate is done
|
||||
in the hosting panel.
|
||||
|
||||
**SealShare.**
|
||||
|
||||
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.1, Pest 5.1 with `pestphp/pest-plugin-browser`
|
||||
(Playwright 1.63). The browser tests run the app in-process, so factories, `Storage::fake()` and
|
||||
`travelTo()` shape what the browser sees. `tests/Pest.php` applies `Tests\TestCase` and
|
||||
`RefreshDatabase` to `Feature` and `Browser`, and creates an admin in `beforeEach` (the setup
|
||||
gate).
|
||||
- Device presets: `visit()->on()->macbook14()` is 1512×982 at 2× (a 3024×1964 capture);
|
||||
`on()->iPhone15Pro()` is 393×852 at 3× (1179×2556). `inLightMode()` / `inDarkMode()`,
|
||||
`screenshot(fullPage, filename)`. Screenshots are written to `tests/Browser/Screenshots/<name>.png`;
|
||||
the directory is created, subdirectories in the name are not — names must be flat. Pest empties
|
||||
that directory when a browser run starts, and has no reduced-motion emulation.
|
||||
- Pest's in-process server does not store a multipart upload, so a browser test cannot select files
|
||||
through the file input.
|
||||
- PHP here has GD with WebP and PNG support; the production image does not need it (this is a
|
||||
development tool).
|
||||
- Colours: `resources/css/material-scheme.json` (seed `#4f46e5`, Vibrant) holds the light and dark
|
||||
roles as hexes. Font: Google Sans Flex, `vendor/nonameweb/livewire-material/resources/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2`
|
||||
with its `OFL.txt`. Logo: `resources/views/components/app-logo-icon.blade.php` (SVG).
|
||||
- **Encryption, as the code does it:** `ShareService::createShare()` encrypts each uploaded file on
|
||||
the server with AES-256-GCM (chunked) through `FileEncryptionService`. Without a share password
|
||||
the key is stored in `shares.encryption_key`; with one, the key is derived with PBKDF2-SHA256 and
|
||||
never stored. The server sees the plaintext while uploading and downloading. The README calls
|
||||
this "End-to-End Encryption", which it is not.
|
||||
- The upload page is public, optionally behind the system password (`SystemPasswordGate`); a
|
||||
customer given that password can upload and send the link back.
|
||||
- Install today (README): `ghcr.io/surtic86/sealshare`, clone from GitHub. Gitea
|
||||
(`gitea.nonameweb.ch/noNameWEB/SealShare`) is now the public repository; the image registry for
|
||||
2.0.0 is settled separately.
|
||||
- `.gitignore` does not ignore `tests/Browser/Screenshots`; `.dockerignore` excludes `tests` and
|
||||
`*.md` but would copy a `website/` directory into the image.
|
||||
|
||||
**Peers.** Pingvin Share has been archived since June 2025 (its README points to forks such as
|
||||
Pingvin Share X). PsiTransfer, Gokapi and Erugo are single-purpose self-hosted share tools; Gokapi
|
||||
advertises end-to-end encryption.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Positioning: software you host, not a service** — the site says plainly that SealShare is not
|
||||
hosted by anyone but the company that installs it: its own upload platform for exchanging files
|
||||
with customers, data on its own server, no dependence on an external service.
|
||||
- **Pages: `index.html` and `privacy.html`** — one landing page, and a short privacy page because
|
||||
the site uses Plausible. No terms page: the software is MIT-licensed and no service is offered.
|
||||
- **A comparison with hosted transfer services and with self-hosted share tools** — two tables on
|
||||
the landing page. Cloud suites (Nextcloud-style) are left out.
|
||||
- **Hand-written HTML and CSS, like MailifySMS** — no build step; `website/` is uploaded as it is.
|
||||
- **Colours copied from `material-scheme.json`, not sampled** — `website/css/theme.css` lists the
|
||||
roles it uses with the scheme's hexes, light by default and dark under
|
||||
`@media (prefers-color-scheme: dark)`; it names the seed and variant it was copied from, so a
|
||||
regenerated scheme is copied again. The site follows the visitor's system theme and has no
|
||||
toggle of its own.
|
||||
- **Google Sans Flex, self-hosted** — the app's font, copied with its `OFL.txt` into
|
||||
`website/fonts/`; nothing is loaded from Google.
|
||||
- **Uploaded by hand, like MailifySMS** — `website/` is a faithful copy of what is live. You create
|
||||
the subdomain and certificate once in the hosting panel and upload the folder when it changes.
|
||||
No hosting credentials anywhere in the repository or CI.
|
||||
- **Plausible** — `data-domain="sealshare.nonameweb.ch"`, the same script as MailifySMS; the site
|
||||
must be added in the Plausible account.
|
||||
- **English only.**
|
||||
- **Screenshots: desktop and phone, each in light and dark (20 images)** —
|
||||
desktop (MacBook 14, 2×): `01-upload` (files selected, options filled), `02-share-created`,
|
||||
`03-qr-code` (the dialog), `04-download` (the recipient's file list), `05-admin-dashboard`,
|
||||
`06-admin-settings`; phone (iPhone 15 Pro, 3×): `01-upload`, `02-password` (the recipient's
|
||||
password prompt), `03-download`, `04-qr-code`.
|
||||
- **Screenshots run as Pest browser tests in `tests/Screenshots/`, started by `composer screenshots`**
|
||||
— the directory is not one of phpunit.xml's test suites, so `php artisan test`, the Browser
|
||||
suite and CI never run it. It reuses Playwright and the in-process server.
|
||||
- **Fixed demo data** — factories and `ShareService` with fixed names, sizes and tokens, time
|
||||
frozen with `travelTo()`, the site title and branding at their defaults, one admin
|
||||
("Alex Morgan"). Every run produces the same images unless the UI changed.
|
||||
- **Images published as WebP by the test run itself** — after each capture a small helper resizes
|
||||
it with GD into `website/img/screenshots/{desktop,phone}/{light,dark}/NN-name-<width>.webp` at two widths
|
||||
(desktop 1600 and 800 px, phone 1080 and 540 px) for `srcset`. Raw PNGs stay in
|
||||
`tests/Browser/Screenshots/`, which is gitignored; only the WebP files are committed.
|
||||
- **Device frames in CSS** — `website/css/device-frame.css` draws a laptop and a phone around the
|
||||
screenshots; the hero shows the desktop upload and the phone download screenshots framed, in
|
||||
the visitor's theme. No generated hero image.
|
||||
- **README shows three screenshots** — desktop upload, desktop share created, phone download (light),
|
||||
referenced from `website/img/screenshots/…`, so each image exists once in the repository.
|
||||
- **Encryption is described accurately, on the site and in the README** — "encrypted at rest with
|
||||
AES-256-GCM; with a share password the key is never stored". The comparison marks end-to-end
|
||||
encryption "no" for SealShare. The README's "End-to-End Encryption" line is corrected.
|
||||
- **Built on `material`, live with 2.0.0** — screenshots show the 2.0.0 interface; the site links
|
||||
the Gitea repository, and its install commands are the README's at release, whatever registry
|
||||
2.0.0 ships with.
|
||||
- **Comparison facts are researched, dated and sourced** — from each product's own site,
|
||||
documentation or repository; the tables say "as of <month year>" and link every source. Hosted
|
||||
services: WeTransfer, SwissTransfer, Dropbox Transfer, Google Drive links. Self-hosted tools:
|
||||
open source, single-purpose, installable with Docker, with a release in the 12 months before the
|
||||
research — expected Pingvin Share X, PsiTransfer, Gokapi and Erugo; any that fails the rule is
|
||||
dropped and named in the commit message. Criteria (rows): where files are stored, who operates
|
||||
it, recipient needs an account, password protection, expiry, download limit, encryption at rest,
|
||||
end-to-end encryption, folder upload, custom branding, maximum file size, licence and cost. A
|
||||
value that cannot be sourced is "—", never guessed.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- A terms page, a documentation section, a German version.
|
||||
- Deploy automation (Gitea Actions, SFTP scripts), and creating the subdomain, certificate or
|
||||
Plausible site — those are yours in the hosting panel and the Plausible account.
|
||||
- Comparing with Nextcloud, ownCloud or other cloud suites.
|
||||
- Store-style canvases with captions, a generated hero image, video or animated screenshots.
|
||||
- Running the screenshots in CI, or checking them against earlier runs (visual regression).
|
||||
- The image registry move and the install commands' final form (settled with 2.0.0).
|
||||
- Implementing end-to-end encryption.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Housekeeping.** `.gitignore`: `/tests/Browser/Screenshots`. `.dockerignore`: `website`.
|
||||
2. **Screenshot helper.** `tests/Screenshots/Publisher.php` (`Tests\Screenshots\Publisher`):
|
||||
`publish(string $capture, string $device, string $theme, string $name, array $widths): void`
|
||||
reads `tests/Browser/Screenshots/<capture>.png` with GD, and for each width writes
|
||||
`website/img/screenshots/<device>/<theme>/<name>-<width>.webp` (quality 82, aspect kept,
|
||||
directories created). It throws when the capture is missing, so a failed shot fails the run.
|
||||
3. **Demo data.** `tests/Screenshots/DemoData.php`: `admin()`, `shares()` (eight shares with fixed
|
||||
tokens, file names such as `Q3-report.pdf`, `Contract 2026.pdf`, `Product photos/…`, sizes,
|
||||
download counts and expiries, one password-protected, one expired), created through factories
|
||||
and `ShareService` with `Storage::fake('shares')` so the files exist encrypted.
|
||||
4. **Screenshot tests.** `tests/Screenshots/ScreenshotsTest.php`, with `tests/Pest.php` extended to
|
||||
`->in('Feature', 'Browser', 'Screenshots')`:
|
||||
- `beforeEach`: `config(['session.driver' => 'file'])`, `travelTo('2026-10-01 09:30')`, demo
|
||||
data; a `ready()` wait as in `SealShareTest`.
|
||||
- One test per device and theme (four tests), each visiting the pages in turn, waiting for
|
||||
`networkidle` and fonts (`document.fonts.ready`), hiding the text caret, capturing
|
||||
viewport-sized (not full-page) shots, and calling `Publisher::publish()` right after each.
|
||||
- The upload shot with files selected: create Livewire temporary uploads on the fake
|
||||
`livewire-tmp` disk and set the uploader's property through `$wire.$set` with
|
||||
`livewire-file:` references, then fill the options. If Livewire refuses that, the shot shows
|
||||
the drop zone with the options filled instead, and the plan's risk note is updated.
|
||||
- QR dialog: `click('[data-test="show-qr-code"]')`; password prompt: the protected share on the
|
||||
phone; admin pages as the admin.
|
||||
5. **Command.** `composer.json` script `"screenshots"`: `Composer\\Config::disableProcessTimeout`,
|
||||
`npm run build`, `@php vendor/bin/pest tests/Screenshots` — the build first, so the shots show
|
||||
the current assets. Playwright's Chromium must be installed (`npx playwright install chromium`),
|
||||
as for the browser tests.
|
||||
6. **Website scaffold.** `website/`:
|
||||
- `css/theme.css` — tokens copied from `material-scheme.json` (with seed and variant noted),
|
||||
Google Sans Flex `@font-face`, layout, nav, hero, sections, cards, tables (scrolling
|
||||
sideways on a phone), FAQ (`<details>`), footer; light and dark through
|
||||
`prefers-color-scheme`.
|
||||
- `css/device-frame.css` — laptop and phone frames.
|
||||
- `fonts/GoogleSansFlex-Latin.woff2`, `fonts/OFL.txt`; `img/logo.svg` (from `app-logo-icon`),
|
||||
`img/icon.png` (favicon, from `public/`).
|
||||
7. **Landing page.** `website/index.html` (Plausible in `<head>`, one inline script at the end):
|
||||
- nav: Why, Features, Screenshots, Compare, Install, FAQ, Gitea;
|
||||
- hero: "Your own secure upload platform" — self-hosted file exchange with customers, no
|
||||
outside service; buttons "Install" (to #install) and "Source on Gitea"; framed desktop and
|
||||
phone screenshots as `<picture>` elements whose `<source media="(prefers-color-scheme: dark)">`
|
||||
picks the dark captures;
|
||||
- "Why run your own": your server, your domain and branding, customers upload and download
|
||||
without accounts, encrypted at rest, no per-seat pricing;
|
||||
- "How it works": upload → link or QR code → the customer downloads, with expiry, download
|
||||
limit and password;
|
||||
- features (from the README, accurate encryption wording);
|
||||
- screenshots: Desktop/Phone and Light/Dark switches over one gallery (Light/Dark starting on
|
||||
the visitor's system theme), `srcset` for both widths, `loading="lazy"`, descriptive `alt`;
|
||||
- compare: the two dated tables with sources (step 8);
|
||||
- install: the README's Docker quick start and a link to the full instructions on Gitea;
|
||||
- FAQ: "Is it end-to-end encrypted?" (no — at rest, and what a password adds), "Can customers
|
||||
send files to us?" (yes, through the upload page, optionally behind the system password),
|
||||
"How big can files be?" (the README's large-file limits), "What does it cost?" (MIT, your
|
||||
hosting), "Who runs it?" (you);
|
||||
- contact (`surtic86@gmail.com`, as MailifySMS) and footer (Gitea, licence, privacy, noNameWEB).
|
||||
8. **Comparison research.** For each product, record every criterion with its source URL and the
|
||||
date checked; apply the self-hosted selection rule; fill the tables. Keep the notes in the
|
||||
commit message, not in the repository.
|
||||
9. **Privacy page.** `website/privacy.html`: who runs the site (contact), the host (METANET, server
|
||||
logs), Plausible (cookieless, no personal data, EU-hosted, link to its data policy), no other
|
||||
third parties, fonts served locally, contact for questions; dated.
|
||||
10. **README and changelog.** Correct the encryption lines (the intro sentence stays accurate;
|
||||
"End-to-End Encryption" becomes "Encryption at Rest", described as in Decisions), add a
|
||||
Screenshots section with the three images, add the website link. CHANGELOG `2.0.0` "Fixed":
|
||||
the README no longer calls the encryption end-to-end. The website and the screenshot tooling
|
||||
get no changelog entry — they do not change the application.
|
||||
11. **Project notes.** `record-rule` for `website/**`: `website/` is a faithful copy of what is live,
|
||||
uploaded by hand; its colours are copied from `material-scheme.json` and must be copied again
|
||||
when the scheme is regenerated; the comparison is dated and every value sourced. And for
|
||||
`tests/Screenshots/**`: run with `composer screenshots` whenever the interface changes, before
|
||||
a release; the demo data is fixed so runs are reproducible.
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/Unit/ScreenshotPublisherTest.php`: a generated PNG is written as WebP at each requested
|
||||
width with the aspect ratio kept, into the device/theme directory; a missing capture throws.
|
||||
- `tests/Feature/WebsiteTest.php` guards `website/` without a browser:
|
||||
- every local `src`, `href`, `srcset` entry and CSS `url()` resolves to a file in `website/`;
|
||||
- every screenshot the gallery or README references exists for both widths and both themes;
|
||||
- no request goes to a host other than `plausible.io` (no Google Fonts, no CDN);
|
||||
- `index.html` and `privacy.html` have a `<title>`, `lang="en"` and a meta description;
|
||||
- the README has no "End-to-End Encryption" line, the site's features section does not say
|
||||
"end-to-end", and SealShare's end-to-end cell in the comparison (marked
|
||||
`data-compare="sealshare-e2e"`) reads "No" — the FAQ may still ask the question.
|
||||
- The screenshot run itself is the test of step 4: it fails when a page, selector or capture
|
||||
breaks. It is run by hand before a release, not in CI.
|
||||
- The site is looked at in Chrome, Firefox and Safari, light and dark, at phone width, before
|
||||
uploading.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Selecting files in the upload shot** works: the files are stored with Livewire's own
|
||||
`FileUploadConfiguration::storeTemporaryFile()` and handed to `_finishUpload` by their signed
|
||||
names; Livewire's temporary-upload cleanup is turned off for the run, because under the frozen
|
||||
clock it deletes them.
|
||||
- **Comparison accuracy and fairness.** Other products change; the tables are dated and sourced,
|
||||
and re-checked when the site is updated. Swiss unfair-competition law expects comparisons to be
|
||||
accurate and not misleading — values that cannot be sourced stay "—".
|
||||
- **Install commands depend on the registry move.** Until it is settled, the install section copies
|
||||
the current README; it is updated before the site goes live with 2.0.0.
|
||||
- **Screenshot determinism.** Relative dates ("in 3 days") depend on `travelTo()`; animations
|
||||
(the share-created shape, counting stats, dialog entry) are waited out — Pest has no
|
||||
reduced-motion emulation — by waiting on `document.getAnimations().length === 0` before each
|
||||
capture.
|
||||
- **The host's name in the privacy page** (METANET) is inferred from the server's reverse DNS
|
||||
(`urbanus.ch-meta.net`); confirm it before the page goes live.
|
||||
- **The contact address** is the one MailifySMS publishes (`surtic86@gmail.com`); change it in
|
||||
step 7 if SealShare should have its own.
|
||||
- **Image weight.** Twenty screenshots at two widths as WebP should stay under ~4 MB in total; if
|
||||
not, lower the quality or drop the larger phone width.
|
||||
- **Colours drift** when the scheme is regenerated; the rule in step 11 and the note in
|
||||
`theme.css` are the guard.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Share by QR code and share sheet
|
||||
|
||||
## Goal
|
||||
|
||||
After an upload, the share created page offers two more ways to hand a share over besides
|
||||
copying the link: a QR code, in a dialog, that another device scans (and that downloads as a
|
||||
PNG for chats and mails), and, where the browser has one, the device's native share sheet. Both
|
||||
carry only the share's link — never a password.
|
||||
|
||||
## Context
|
||||
|
||||
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.x, Pest 5 with browser tests, Octane
|
||||
(FrankenPHP). Production image `dunglas/frankenphp:php8.5-alpine` with `intl`, `pcntl`, `zip`
|
||||
added; it has `xmlwriter` and `iconv`, and neither `gd` nor `imagick`.
|
||||
- `bacon/bacon-qr-code` v3.1.1 is installed through `laravel/fortify` (`^3.0`), which draws the
|
||||
two-factor setup QR with `Writer` + `ImageRenderer` + `SvgImageBackEnd` and strips the XML
|
||||
declaration (`TwoFactorAuthenticatable::twoFactorQrCodeSvg()`). SVG needs no image extension;
|
||||
a server-side PNG would.
|
||||
- `app/Livewire/ShareCreated.php` (`#[Layout('layouts.app')]`, `public Share $share`) renders
|
||||
`resources/views/livewire/share-created.blade.php`: the link as
|
||||
`<x-input :value="route('share.download', $share)" readonly copyable data-test="share-link">`,
|
||||
four `<x-stat>`, an info alert for password-protected shares, and "Upload More". The route
|
||||
`share/{share:token}/created` sits behind `system.password`, like the upload page.
|
||||
- The two-factor dialog (`pages/settings/⚡two-factor`) is the in-app pattern: the SVG inline
|
||||
on a `bg-white` panel inside `<x-modal fullscreen>`, so it stays scannable in dark mode.
|
||||
- `<x-modal>` without `wire:model` opens from `open` in the surrounding Alpine scope and gives
|
||||
`close()`; `materialToast()` is global; `resources/js/app.js` imports only the package JS.
|
||||
- Services live in `app/Services` (`ShareService`, `FileEncryptionService`). Share passwords
|
||||
are hashed and turned into a key; the plain password is never stored.
|
||||
- No feature test covers `ShareCreated` yet; `tests/Browser/SealShareTest.php` copies the link
|
||||
on that page.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Only on the share created page** — that is where a share is handed over; the admin
|
||||
dashboard and the download page stay as they are.
|
||||
- **A "Show QR code" button opens a dialog** — the page stays as calm as now; the dialog is
|
||||
`<x-modal fullscreen>` (the whole screen on a phone, to hold up to another camera) with the
|
||||
QR on a white panel.
|
||||
- **Download is a PNG made in the browser** — the dialog's SVG is drawn onto a canvas and saved
|
||||
as `share-<token>.png`; no server route and no `gd`/`imagick` in the Docker images.
|
||||
- **Require `bacon/bacon-qr-code:^3.0` directly** — the version already installed through
|
||||
Fortify, declared so SealShare does not depend on Fortify keeping it.
|
||||
- **Password-protected shares get a note in the dialog** — "Recipients also need the password."
|
||||
The QR holds the link only.
|
||||
- **A "Share…" button opens the native share sheet** — shown only where `navigator.share` exists
|
||||
(mostly phones and Safari), sharing `{ title: <site title>, url: <share link> }`.
|
||||
Cancelling the sheet (`AbortError`) does nothing; any other failure shows an error snackbar.
|
||||
- **In 2.0.0, on the `material` branch** — 2.0.0 is not released and the page was just rebuilt
|
||||
there; one PR, one changelog entry.
|
||||
- **Black modules on white, a four-module quiet zone, error correction M** — the most reliable
|
||||
to scan from a screen or a print; the site's theme does not tint it.
|
||||
- **The SVG is drawn at 1024 × 1024** — CSS scales it down in the dialog, and the canvas draws it
|
||||
at its own size, so the PNG is sharp in every browser (Safari rasterises an SVG at its
|
||||
intrinsic size).
|
||||
- **Generated server-side with the page, opened client-side** — the SVG is a few kilobytes and
|
||||
the dialog needs no round trip; the dialog's `open` is Alpine state, not a Livewire property.
|
||||
- **`App\Services\QrCodeService::svg(string $contents): string`** — one place that knows Bacon's
|
||||
API; `ShareCreated::render()` passes `shareUrl`, `qrCodeSvg` and `siteTitle` to the view (as
|
||||
`FileUploader::render()` passes `siteTitle`), so the URL is built once.
|
||||
- **The share and download behaviour lives in `resources/js/share-created.js`** — an
|
||||
`Alpine.data('shareActions', …)` with `canShare`, `share()` and `downloadQrCode()`, imported
|
||||
by `app.js`, instead of long inline Alpine in the view.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- QR codes on the admin dashboard or the download page.
|
||||
- Sharing the QR image itself through the share sheet (`navigator.share({ files })`).
|
||||
- An SVG download, a server-rendered PNG, or a print layout.
|
||||
- A logo in the middle of the QR, or colours from the theme.
|
||||
- Putting the password (or any secret beyond the link's token) into the QR or the share sheet.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Dependency.** `composer require bacon/bacon-qr-code:^3.0` (stays at v3.1.1).
|
||||
2. **Service.** `php artisan make:class Services/QrCodeService`: `svg(string $contents): string`
|
||||
renders with `new Writer(new ImageRenderer(new RendererStyle(1024, 4, null, null,
|
||||
Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0))), new SvgImageBackEnd))`,
|
||||
`writeString($contents, Encoder::DEFAULT_BYTE_MODE_ENCODING, ErrorCorrectionLevel::M())`, and
|
||||
drops the XML declaration as Fortify does.
|
||||
3. **Component.** `ShareCreated::render()` builds `$shareUrl = route('share.download',
|
||||
$this->share)` and passes `shareUrl`, `qrCodeSvg` (from the service) and `siteTitle`
|
||||
(`Setting::get('site_title') ?: config('app.name')`) to the view; the link field uses
|
||||
`$shareUrl`.
|
||||
4. **JavaScript.** `resources/js/share-created.js` registers on `alpine:init`
|
||||
`Alpine.data('shareActions', ({ url, title, filename, messages }) => …)`, `messages` holding
|
||||
the translated `shareFailed` and `downloadFailed`:
|
||||
- `open: false` for the dialog;
|
||||
- `canShare`: `typeof navigator.share === 'function'`, read once at init;
|
||||
- `share()`: `navigator.share({ title, url })`, ignoring `AbortError`, otherwise
|
||||
`materialToast(messages.shareFailed, { type: 'error' })`;
|
||||
- `downloadQrCode(svg)`: takes the `<svg>` element (the button passes
|
||||
`$el.closest('dialog').querySelector('[data-qr-code] svg')` — the dialog has its own Alpine
|
||||
scope, so `$refs` from the outer one would not reach it), serialises it, loads it into an
|
||||
`Image` from a Blob URL, draws it on a 1024 × 1024 canvas with a white fill and
|
||||
`imageSmoothingEnabled = false`, `toBlob('image/png')`, clicks a temporary `<a download>`
|
||||
named `filename`, and revokes both object URLs; a failed load or an empty blob shows
|
||||
`materialToast(messages.downloadFailed, { type: 'error' })`.
|
||||
`resources/js/app.js` imports it after the package.
|
||||
5. **View.** In `share-created.blade.php`, wrap the link and actions in
|
||||
`<div x-data="shareActions({ url: @js($shareUrl), title: @js($siteTitle), filename: @js('share-'.$share->token.'.png'), messages: @js(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) })">`
|
||||
(a plain element, so `@js` compiles there):
|
||||
- under the link field, a row with `<x-button :label="__('Show QR code')" icon="qr_code_2"
|
||||
variant="tonal" x-on:click="open = true" data-test="show-qr-code" />` and, in a
|
||||
`<span x-show="canShare" x-cloak>` wrapper, `<x-button :label="__('Share…')" icon="share"
|
||||
variant="tonal" x-on:click="share()" data-test="share-sheet" />`;
|
||||
- `<x-modal fullscreen :title="__('Scan to open the share')">` holding
|
||||
`<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">{!! $qrCodeSvg !!}</div>`
|
||||
(the SVG is generated from the app's own URL — no user input), then, for a
|
||||
password-protected share, `<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />`,
|
||||
and actions `<x-button :label="__('Download')" icon="download" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />`
|
||||
and `<x-button :label="__('Close')" x-on:click="close()" />`.
|
||||
"Upload More" and the stats stay where they are.
|
||||
6. **Docs.** README: the "Shareable Links" feature line mentions the QR code and share sheet.
|
||||
CHANGELOG `2.0.0`: an "Added" section (before "Changed", as Keep a Changelog orders them)
|
||||
with an entry for both.
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/Unit/QrCodeServiceTest.php` (the service needs no application):
|
||||
`svg()` returns markup starting with `<svg`, without an XML declaration, 1024 wide, and the
|
||||
same markup for the same contents and different markup for different contents.
|
||||
- `tests/Feature/ShareCreatedTest.php` (new): the page shows the link, and its HTML contains
|
||||
exactly `QrCodeService::svg(route('share.download', $share))` inside the dialog, the
|
||||
"Show QR code" and "Share…" buttons, and the download filename `share-<token>.png`; the
|
||||
password note appears for a protected share and not for an open one.
|
||||
- `tests/Browser/SealShareTest.php`:
|
||||
- "Show QR code" opens the dialog with the QR on a white panel; Download produces an
|
||||
`image/png` blob named `share-<token>.png` (recorded by stubbing
|
||||
`HTMLAnchorElement.prototype.click` through `window.eval`), with no JavaScript errors;
|
||||
- the Share button is hidden where `navigator.share` is missing, and `share()` passes the
|
||||
link to a stubbed `navigator.share`, stays quiet on `AbortError` and shows the error snackbar
|
||||
on any other rejection.
|
||||
- `DesignLanguageTest` keeps passing (`qr_code_2`, `share`, `download` are Material Symbols;
|
||||
`bg-white` is a token).
|
||||
- Narrow runs per step, then the full suite.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Scanning reliability** is not proven by the tests (no decoder in the stack): the feature
|
||||
test pins the SVG to Bacon's output for the exact URL, and a manual scan with a phone during
|
||||
review is the check.
|
||||
- **The QR is only as right as the link.** Behind a reverse proxy with a wrong `APP_URL` or
|
||||
trusted-proxy setting, both point at the wrong host — unchanged from today.
|
||||
- **Safari and canvas.** Drawing an SVG from a Blob URL onto a canvas works in current
|
||||
Chrome, Firefox and Safari without tainting the canvas; the browser test runs in Chromium
|
||||
locally and in CI, and the three-engine check is manual.
|
||||
- **Share sheet on desktop** exists in Safari and Chromium on some platforms and not in
|
||||
Firefox; the button's absence there is by design.
|
||||
Generated
+446
-252
@@ -1,59 +1,26 @@
|
||||
{
|
||||
"name": "app",
|
||||
"name": "SealShare",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"alpinejs": "^3.15.12",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"concurrently": "^10.0.3",
|
||||
"daisyui": "^5.7.0",
|
||||
"laravel-vite-plugin": "^3.1.3",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"concurrently": "^10.0.5",
|
||||
"laravel-vite-plugin": "^3.2.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.1.5"
|
||||
"vite": "^8.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar": "^5.0.0"
|
||||
"chokidar": "^5.0.0",
|
||||
"playwright": "^1.63.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
||||
"lightningcss-linux-x64-gnu": "^1.29.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -84,9 +51,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
@@ -99,37 +66,35 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.149.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
|
||||
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
|
||||
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -143,9 +108,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -159,9 +124,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
|
||||
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -175,9 +140,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
|
||||
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -191,9 +156,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
|
||||
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -207,9 +172,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -226,9 +191,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
|
||||
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -245,9 +210,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -264,9 +229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -283,9 +248,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -302,9 +267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
|
||||
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -321,9 +286,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -336,28 +301,10 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.11.1",
|
||||
"@emnapi/runtime": "1.11.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
|
||||
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -371,9 +318,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
|
||||
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -661,44 +608,10 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.15.12",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
|
||||
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -720,9 +633,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.4",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
|
||||
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
|
||||
"version": "10.5.5",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.5.tgz",
|
||||
"integrity": "sha512-uiRYvQYe/nNSzBJ7OUnd2/TZVsAdob3blml44teEpee9Cc1f4rGZFewO+JT3Wo8mgFOSzNqes4FHZn/Qz8WOuw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -739,8 +652,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.6",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"browserslist": "^4.28.9",
|
||||
"caniuse-lite": "^1.0.30001810",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
@@ -756,9 +669,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
|
||||
"integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==",
|
||||
"version": "2.11.21",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
|
||||
"integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
@@ -768,9 +681,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.7",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
|
||||
"version": "4.28.9",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
|
||||
"integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -787,11 +700,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"electron-to-chromium": "^1.5.393",
|
||||
"node-releases": "^2.0.51",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
"baseline-browser-mapping": "^2.11.20",
|
||||
"caniuse-lite": "^1.0.30001810",
|
||||
"electron-to-chromium": "^1.5.420",
|
||||
"node-releases": "^2.0.54",
|
||||
"update-browserslist-db": "^1.3.2"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -801,9 +714,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"version": "1.0.30001810",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
|
||||
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -863,14 +776,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
|
||||
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
|
||||
"version": "10.0.5",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
|
||||
"integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.4",
|
||||
"shell-quote": "1.9.0",
|
||||
"supports-color": "10.2.2",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "18.0.0"
|
||||
@@ -886,15 +799,6 @@
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.0.tgz",
|
||||
"integrity": "sha512-2/kYbxaKtv349lPrTyxMKC9SHsyA7fBULMSabJljDE82D079cjqz+UyAzsogWgy4sTs5NDvD000acfcFqbO1XA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -905,9 +809,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.395",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz",
|
||||
"integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==",
|
||||
"version": "1.5.425",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.425.tgz",
|
||||
"integrity": "sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
@@ -917,9 +821,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.24.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz",
|
||||
"integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==",
|
||||
"version": "5.24.5",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
|
||||
"integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
@@ -1019,9 +923,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-vite-plugin": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
|
||||
"integrity": "sha512-cI5Anw4QHY+UzvZczFaj+j8NhwT2FtyEN8aqS/hOdt6DpEFBsn6x3GENxALem3cc+TsGvd9MacneimEvShvKMA==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
|
||||
"integrity": "sha512-xSxY9Gzeb/eancd8WeK09piAFP+a6i5QIBqNCKNv9L0Eq6wziwzSem7F1GvMSrtjMh5F/QVKFxn8t9naGOA66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.0.0",
|
||||
@@ -1220,9 +1124,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1305,6 +1209,29 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss/node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1315,9 +1242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1333,9 +1260,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||
"version": "2.0.55",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
|
||||
"integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -1348,9 +1275,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -1359,10 +1286,39 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.22",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
|
||||
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1379,7 +1335,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -1394,9 +1350,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
|
||||
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1408,12 +1364,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
|
||||
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@oxc-project/types": "=0.149.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1423,21 +1379,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.8",
|
||||
"@rolldown/binding-android-arm64": "1.2.8",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.8",
|
||||
"@rolldown/binding-darwin-x64": "1.2.8",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.8",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.8",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.8",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.8",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
@@ -1450,9 +1406,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
|
||||
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -1565,9 +1521,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
|
||||
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1595,15 +1551,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
|
||||
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"postcss": "^8.5.26",
|
||||
"rolldown": "~1.2.4",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1620,7 +1576,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.3.0",
|
||||
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -1693,6 +1649,244 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
|
||||
+9
-7
@@ -8,19 +8,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"alpinejs": "^3.15.12",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"concurrently": "^10.0.3",
|
||||
"daisyui": "^5.7.0",
|
||||
"laravel-vite-plugin": "^3.1.3",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"concurrently": "^10.0.5",
|
||||
"laravel-vite-plugin": "^3.2.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.1.5"
|
||||
"vite": "^8.2.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
||||
"lightningcss-linux-x64-gnu": "^1.29.1"
|
||||
},
|
||||
"overrides": {
|
||||
"shell-quote": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar": "^5.0.0"
|
||||
"chokidar": "^5.0.0",
|
||||
"playwright": "^1.63.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Browser">
|
||||
<directory>tests/Browser</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
|
||||
+17
-5
@@ -1,10 +1,22 @@
|
||||
@import 'tailwindcss';
|
||||
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
|
||||
@import './material-scheme.css';
|
||||
|
||||
@source '../views';
|
||||
@source '../../vendor/robsontenorio/mary/src/View/Components/**/*.php';
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source inline("swap swap-rotate swap-on swap-off theme-controller");
|
||||
@source '../../vendor/nonameweb/livewire-material/resources/views';
|
||||
@source '../../vendor/nonameweb/livewire-material/src';
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: light --default, dark --prefersdark;
|
||||
/* share-created: the check on its shape settles in once the link is ready. */
|
||||
@keyframes share-ready {
|
||||
from {
|
||||
opacity: 0;
|
||||
rotate: -90deg;
|
||||
scale: 0.4;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
rotate: 0deg;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -1,2 +1,3 @@
|
||||
// Alpine.js is bundled and started automatically by Livewire 4.
|
||||
// Do not import it here to avoid "multiple instances of Alpine" errors.
|
||||
// Livewire Material. Alpine is bundled and started by Livewire 4: never import it here as well.
|
||||
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||||
import './share-created.js'
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* `shareActions`: the ways the share created page hands a share over besides copying its link —
|
||||
* the device's share sheet, where the browser has one, and the QR code in its dialog saved as a
|
||||
* PNG. Both carry only the link.
|
||||
*
|
||||
* The PNG is drawn in the browser from the dialog's SVG, which is 1024 pixels square, so the
|
||||
* server needs no image extension and every browser rasterises it at full size.
|
||||
*/
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('shareActions', ({ url, title, filename, messages }) => ({
|
||||
open: false,
|
||||
|
||||
canShare: typeof navigator.share === 'function',
|
||||
|
||||
async share() {
|
||||
try {
|
||||
await navigator.share({ title, url })
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
window.materialToast(messages.shareFailed, { type: 'error' })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async downloadQrCode(svg) {
|
||||
try {
|
||||
const png = await rasterise(svg)
|
||||
const link = document.createElement('a')
|
||||
const href = URL.createObjectURL(png)
|
||||
|
||||
link.href = href
|
||||
link.download = filename
|
||||
link.click()
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(href), 0)
|
||||
} catch {
|
||||
window.materialToast(messages.downloadFailed, { type: 'error' })
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* The SVG element as a PNG blob, at the SVG's own width and height, on white.
|
||||
*/
|
||||
async function rasterise(svg) {
|
||||
const width = Number(svg.getAttribute('width'))
|
||||
const height = Number(svg.getAttribute('height'))
|
||||
const source = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' }))
|
||||
|
||||
try {
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
context.imageSmoothingEnabled = false
|
||||
context.fillStyle = '#ffffff'
|
||||
context.fillRect(0, 0, width, height)
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
|
||||
const png = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||||
|
||||
if (!png) {
|
||||
throw new Error('The canvas gave no image.')
|
||||
}
|
||||
|
||||
return png
|
||||
} finally {
|
||||
URL.revokeObjectURL(source)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
@props([
|
||||
'on',
|
||||
])
|
||||
|
||||
<div
|
||||
x-data="{ shown: false, timeout: null }"
|
||||
x-init="@this.on('{{ $on }}', () => { clearTimeout(timeout); shown = true; timeout = setTimeout(() => { shown = false }, 2000); })"
|
||||
x-show.transition.out.opacity.duration.1500ms="shown"
|
||||
x-transition:leave.opacity.duration.1500ms
|
||||
style="display: none"
|
||||
{{ $attributes->merge(['class' => 'text-sm']) }}
|
||||
>
|
||||
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
|
||||
</div>
|
||||
@@ -1,4 +0,0 @@
|
||||
<a href="{{ route('home') }}" {{ $attributes->merge(['class' => 'flex items-center gap-2 font-semibold']) }} wire:navigate>
|
||||
<x-app-logo-icon class="size-6 fill-current" />
|
||||
<span>{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}</span>
|
||||
</a>
|
||||
@@ -4,6 +4,6 @@
|
||||
])
|
||||
|
||||
<div class="flex w-full flex-col text-center">
|
||||
<h2 class="text-xl font-bold">{{ $title }}</h2>
|
||||
<p class="text-sm opacity-60 mt-1">{{ $description }}</p>
|
||||
<h1 class="type-headline-sm">{{ $title }}</h1>
|
||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ $description }}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,5 @@
|
||||
])
|
||||
|
||||
@if ($status)
|
||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
|
||||
{{ $status }}
|
||||
</div>
|
||||
<x-alert color="success" {{ $attributes }}>{{ $status }}</x-alert>
|
||||
@endif
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{{-- Desktop user menu - integrated into sidebar layout --}}
|
||||
@@ -1,12 +0,0 @@
|
||||
@props([
|
||||
'id' => uniqid(),
|
||||
])
|
||||
|
||||
<svg {{ $attributes }} fill="none">
|
||||
<defs>
|
||||
<pattern id="pattern-{{ $id }}" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
|
||||
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect stroke="none" fill="url(#pattern-{{ $id }})" width="100%" height="100%"></rect>
|
||||
</svg>
|
||||
@@ -1,18 +0,0 @@
|
||||
<x-layouts::app :title="__('Dashboard')">
|
||||
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
|
||||
</div>
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
|
||||
</div>
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-base-300">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -1,3 +1,15 @@
|
||||
<x-layouts::app.sidebar :title="$title ?? null">
|
||||
{{ $slot }}
|
||||
</x-layouts::app.sidebar>
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased [--material-bottom-bar:calc(5rem+env(safe-area-inset-bottom))]">
|
||||
<main class="mx-auto w-full max-w-5xl px-4 pt-8 pb-32 sm:px-6 sm:pt-12">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
@include('partials.toolbar')
|
||||
|
||||
<x-toast />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{{-- Header layout not used - redirects to sidebar layout --}}
|
||||
<x-layouts::app.sidebar :title="$title ?? null">
|
||||
{{ $slot }}
|
||||
</x-layouts::app.sidebar>
|
||||
@@ -1,42 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="dark">
|
||||
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('mary-theme')?.replaceAll('"','') || 'dark')</script>
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen font-sans antialiased bg-base-200/50 flex flex-col">
|
||||
|
||||
{{-- MAIN CONTENT --}}
|
||||
<main class="flex-1 w-full max-w-5xl mx-auto px-4 py-8">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
{{-- FOOTER NAV --}}
|
||||
<footer class="border-t border-base-300 bg-base-100/50">
|
||||
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between text-sm">
|
||||
<a href="{{ route('upload') }}" class="font-medium opacity-70 hover:opacity-100 transition-opacity">
|
||||
{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}
|
||||
</a>
|
||||
<nav class="flex items-center gap-4">
|
||||
<x-theme-toggle class="opacity-60 hover:opacity-100 transition-opacity" />
|
||||
@auth
|
||||
@if(auth()->user()->is_admin)
|
||||
<a href="{{ route('admin.dashboard') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Dashboard') }}</a>
|
||||
<a href="{{ route('admin.settings') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Settings') }}</a>
|
||||
@endif
|
||||
<a href="{{ route('profile.edit') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Profile') }}</a>
|
||||
<form method="POST" action="{{ route('logout') }}" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Logout') }}</button>
|
||||
</form>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Login') }}</a>
|
||||
@endauth
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{{-- Toast --}}
|
||||
<x-toast />
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +1,19 @@
|
||||
<x-layouts::auth.simple :title="$title ?? null">
|
||||
{{ $slot }}
|
||||
</x-layouts::auth.simple>
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="flex min-h-dvh flex-col bg-surface font-sans text-on-surface antialiased [--material-bottom-bar:calc(5rem+env(safe-area-inset-bottom))]">
|
||||
<main class="flex flex-1 items-start justify-center px-4 pt-8 pb-32 sm:items-center">
|
||||
<div class="w-full max-w-md rounded-corner-xl bg-surface-container-low p-6 sm:p-8">
|
||||
<div class="flex flex-col gap-6">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@include('partials.toolbar')
|
||||
|
||||
<x-toast />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{{-- Card auth layout - delegates to simple layout --}}
|
||||
<x-layouts::auth.simple :title="$title ?? null">
|
||||
{{ $slot }}
|
||||
</x-layouts::auth.simple>
|
||||
@@ -1,27 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="dark">
|
||||
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('mary-theme')?.replaceAll('"','') || 'dark')</script>
|
||||
<head>
|
||||
@include('partials.head')
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200 antialiased">
|
||||
<div class="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div class="flex w-full max-w-sm flex-col gap-2">
|
||||
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
|
||||
<span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md">
|
||||
<x-app-logo-icon class="size-9 fill-current" />
|
||||
</span>
|
||||
<span class="sr-only">{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}</span>
|
||||
</a>
|
||||
<div class="flex flex-col gap-6">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<x-theme-toggle class="opacity-60 hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +0,0 @@
|
||||
{{-- Split auth layout - delegates to simple layout --}}
|
||||
<x-layouts::auth.simple :title="$title ?? null">
|
||||
{{ $slot }}
|
||||
</x-layouts::auth.simple>
|
||||
@@ -1,70 +1,68 @@
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('Admin Dashboard') }}</h1>
|
||||
<h1 class="mb-6 type-headline-md">{{ __('Admin Dashboard') }}</h1>
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Total Shares') }}</p>
|
||||
<p class="text-2xl font-bold">{{ $totalShares }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Active Shares') }}</p>
|
||||
<p class="text-2xl font-bold">{{ $activeShares }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Total Files') }}</p>
|
||||
<p class="text-2xl font-bold">{{ $totalFiles }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Disk Usage') }}</p>
|
||||
<p class="text-2xl font-bold">{{ Number::fileSize($usedSpace) }}</p>
|
||||
<progress class="progress progress-primary w-full mt-1" value="{{ $maxQuota > 0 ? ($usedSpace / $maxQuota) * 100 : 0 }}" max="100"></progress>
|
||||
<p class="text-xs opacity-50">{{ Number::fileSize($usedSpace) }} / {{ Number::fileSize($maxQuota) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<x-stat :title="__('Total Shares')" :value="$totalShares" icon="link" />
|
||||
<x-stat :title="__('Active Shares')" :value="$activeShares" icon="schedule" />
|
||||
<x-stat :title="__('Total Files')" :value="$totalFiles" icon="description" />
|
||||
<x-stat :title="__('Disk Usage')" :value="Number::fileSize($usedSpace)" icon="hard_drive" :description="Number::fileSize($usedSpace).' / '.Number::fileSize($maxQuota)">
|
||||
<x-progress :value="$maxQuota > 0 ? min(100, ($usedSpace / $maxQuota) * 100) : 0" class="mt-2" :label="__('Disk Usage')" />
|
||||
</x-stat>
|
||||
</div>
|
||||
|
||||
{{-- Shares Table --}}
|
||||
<x-card title="{{ __('All Shares') }}" shadow>
|
||||
<x-table :headers="$headers" :rows="$shares" :sort-by="$sortBy" with-pagination>
|
||||
@scope('cell_total_size', $share)
|
||||
{{ Number::fileSize($share->total_size) }}
|
||||
@endscope
|
||||
<x-card :title="__('All Shares')" variant="outlined">
|
||||
{{-- Outside the table, so it stays centred on a phone instead of scrolling with the columns. --}}
|
||||
@if ($shares->total() === 0)
|
||||
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
|
||||
@else
|
||||
<div class="-mx-4 overflow-x-auto">
|
||||
<x-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<x-sort-header column="token" :sort-by="$sortBy">{{ __('Token') }}</x-sort-header>
|
||||
<x-sort-header column="files_count" :sort-by="$sortBy" class="text-end">{{ __('Files') }}</x-sort-header>
|
||||
<x-sort-header column="total_size" :sort-by="$sortBy" class="text-end">{{ __('Size') }}</x-sort-header>
|
||||
<x-sort-header column="download_count" :sort-by="$sortBy" class="text-end">{{ __('Downloads') }}</x-sort-header>
|
||||
<x-sort-header column="expires_at" :sort-by="$sortBy">{{ __('Expires') }}</x-sort-header>
|
||||
<x-sort-header column="created_at" :sort-by="$sortBy">{{ __('Created') }}</x-sort-header>
|
||||
<th><span class="sr-only">{{ __('Actions') }}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($shares as $share)
|
||||
<tr wire:key="share-{{ $share->id }}">
|
||||
<td class="font-mono">{{ $share->token }}</td>
|
||||
<td class="text-end tabular-nums">{{ $share->files_count }}</td>
|
||||
<td class="text-end tabular-nums whitespace-nowrap">{{ Number::fileSize($share->total_size) }}</td>
|
||||
<td class="text-end tabular-nums">{{ $share->download_count }}</td>
|
||||
<td class="whitespace-nowrap">
|
||||
@if ($share->expires_at)
|
||||
<span @class(['text-error' => $share->isExpired()])>{{ $share->expires_at->diffForHumans() }}</span>
|
||||
@else
|
||||
<span class="text-on-surface-variant">{{ __('Never') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="whitespace-nowrap">{{ $share->created_at->diffForHumans() }}</td>
|
||||
<td class="text-end whitespace-nowrap">
|
||||
<x-button icon="open_in_new" :tooltip="__('Open')" :link="route('share.download', $share)" external />
|
||||
<x-button icon="delete" :tooltip="__('Delete')" color="error" wire:click="$set('deletingShareId', {{ $share->id }})" data-test="delete-share-{{ $share->id }}" />
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</x-table>
|
||||
</div>
|
||||
|
||||
@scope('cell_expires_at', $share)
|
||||
@if ($share->expires_at)
|
||||
<span class="{{ $share->isExpired() ? 'text-error' : '' }}">
|
||||
{{ $share->expires_at->diffForHumans() }}
|
||||
</span>
|
||||
@else
|
||||
<span class="opacity-50">{{ __('Never') }}</span>
|
||||
@endif
|
||||
@endscope
|
||||
|
||||
@scope('cell_created_at', $share)
|
||||
{{ $share->created_at->diffForHumans() }}
|
||||
@endscope
|
||||
|
||||
@scope('actions', $share)
|
||||
<div class="flex gap-1">
|
||||
<a href="{{ route('share.download', $share) }}" class="btn btn-ghost btn-xs" target="_blank">
|
||||
<x-icon name="o-eye" class="w-4 h-4" />
|
||||
</a>
|
||||
<x-button
|
||||
icon="o-trash"
|
||||
class="btn-ghost btn-xs text-error"
|
||||
wire:click="deleteShare({{ $share->id }})"
|
||||
wire:confirm="{{ __('Are you sure you want to delete this share?') }}"
|
||||
/>
|
||||
</div>
|
||||
@endscope
|
||||
</x-table>
|
||||
<div class="mt-4">{{ $shares->links() }}</div>
|
||||
@endif
|
||||
</x-card>
|
||||
|
||||
<x-modal wire:model="deletingShareId" :title="__('Delete this share?')" icon="delete">
|
||||
{{ __('Are you sure you want to delete this share?') }}
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||||
<x-button :label="__('Delete')" danger x-on:click="$wire.deleteShare($wire.deletingShareId)" data-test="confirm-delete-share" />
|
||||
</x-slot:actions>
|
||||
</x-modal>
|
||||
</div>
|
||||
|
||||
@@ -1,100 +1,70 @@
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('System Settings') }}</h1>
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="mb-6 type-headline-md">{{ __('System Settings') }}</h1>
|
||||
|
||||
@if (session('message'))
|
||||
<div class="alert alert-success mb-6">
|
||||
<x-icon name="o-check-circle" class="w-5 h-5" />
|
||||
<span>{{ session('message') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<form wire:submit="saveSettings" class="grid gap-6">
|
||||
<x-card :title="__('Colour profile')" variant="outlined">
|
||||
<x-scheme-picker wire:model="colorProfile" :hint="__('Choosing one previews it here. After saving, every page, mail and error page uses it.')" data-test="color-profile" />
|
||||
</x-card>
|
||||
|
||||
<form wire:submit="saveSettings">
|
||||
<x-card title="{{ __('Branding') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<x-input
|
||||
wire:model="siteTitle"
|
||||
label="{{ __('Site Title') }}"
|
||||
hint="{{ __('Displayed as the heading on the upload page.') }}"
|
||||
/>
|
||||
<x-card :title="__('Branding')" variant="outlined">
|
||||
<div class="grid gap-5">
|
||||
<x-input wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload page.')" />
|
||||
|
||||
<x-textarea
|
||||
wire:model="siteDescription"
|
||||
label="{{ __('Site Description') }}"
|
||||
hint="{{ __('Displayed below the title on the upload page.') }}"
|
||||
rows="3"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="label label-text font-semibold">{{ __('Logo') }}</label>
|
||||
<x-textarea wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload page.')" rows="3" />
|
||||
|
||||
<div class="grid gap-3">
|
||||
@if ($currentLogo)
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded" />
|
||||
<x-button
|
||||
label="{{ __('Remove Logo') }}"
|
||||
class="btn-sm btn-ghost text-error"
|
||||
wire:click="removeLogo"
|
||||
wire:confirm="{{ __('Remove the logo?') }}"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded-corner-sm" />
|
||||
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<input type="file" wire:model="siteLogo" accept="image/*,.svg,.svgz" class="file-input file-input-bordered w-full" />
|
||||
<x-file wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
|
||||
|
||||
@if ($siteLogo && is_object($siteLogo))
|
||||
<div class="mt-2">
|
||||
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
||||
<p class="text-sm opacity-60">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
||||
@else
|
||||
<p class="text-sm opacity-60">{{ __('Preview:') }}</p>
|
||||
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="h-16 w-auto rounded mt-1" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@error('siteLogo')
|
||||
<p class="text-error text-sm mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
|
||||
<p class="text-xs opacity-50 mt-1">{{ __('Max 2MB. Recommended: PNG or SVG.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-card title="{{ __('Upload Protection') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<x-password
|
||||
wire:model="systemPassword"
|
||||
label="{{ __('System Upload Password') }}"
|
||||
hint="{{ __('Leave blank to keep current. Set a password to require it before uploading.') }}"
|
||||
/>
|
||||
|
||||
@if ($hasSystemPassword)
|
||||
<div class="mt-2">
|
||||
<x-button
|
||||
label="{{ __('Clear System Password') }}"
|
||||
class="btn-sm btn-ghost text-error"
|
||||
wire:click="clearSystemPassword"
|
||||
wire:confirm="{{ __('Remove the system password?') }}"
|
||||
/>
|
||||
</div>
|
||||
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
||||
<p class="type-body-md text-on-surface-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
||||
@else
|
||||
<div>
|
||||
<p class="type-label-lg text-on-surface-variant">{{ __('Preview:') }}</p>
|
||||
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="mt-1 h-16 w-auto rounded-corner-sm" />
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-card title="{{ __('Upload Limits') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<x-card :title="__('Upload Protection')" variant="outlined">
|
||||
<div class="grid gap-3">
|
||||
<x-password
|
||||
wire:model="systemPassword"
|
||||
:label="__('System Upload Password')"
|
||||
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
|
||||
@if ($hasSystemPassword)
|
||||
<div>
|
||||
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-card :title="__('Upload Limits')" variant="outlined">
|
||||
<div class="grid gap-5">
|
||||
<x-toggle
|
||||
wire:model.live="allowNeverExpire"
|
||||
label="{{ __('Allow shares to never expire') }}"
|
||||
hint="{{ __('When disabled, users must select an expiration time.') }}"
|
||||
:label="__('Allow shares to never expire')"
|
||||
:hint="__('When disabled, users must select an expiration time.')"
|
||||
right
|
||||
/>
|
||||
|
||||
<x-select
|
||||
wire:model="defaultExpiration"
|
||||
label="{{ __('Default Expiration') }}"
|
||||
:label="__('Default Expiration')"
|
||||
:placeholder="$allowNeverExpire ? __('None') : null"
|
||||
:options="[
|
||||
['id' => '1h', 'name' => __('1 Hour')],
|
||||
@@ -108,44 +78,49 @@
|
||||
|
||||
<x-input
|
||||
wire:model="maxFileSize"
|
||||
label="{{ __('Max file size (MB)') }}"
|
||||
:label="__('Max file size (MB)')"
|
||||
type="number"
|
||||
min="1"
|
||||
max="{{ $phpMaxUploadMb }}"
|
||||
:max="$phpMaxUploadMb"
|
||||
suffix="MB"
|
||||
hint="{{ __('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb]) }}"
|
||||
:hint="__('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb])"
|
||||
/>
|
||||
|
||||
<x-input
|
||||
wire:model="maxFilesPerShare"
|
||||
label="{{ __('Max files per share') }}"
|
||||
type="number"
|
||||
min="1"
|
||||
/>
|
||||
<x-input wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
|
||||
|
||||
<x-input
|
||||
wire:model="maxSizePerShare"
|
||||
label="{{ __('Max total size per share (GB)') }}"
|
||||
type="number"
|
||||
min="1"
|
||||
suffix="GB"
|
||||
/>
|
||||
<x-input wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-card title="{{ __('Storage') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<x-input
|
||||
wire:model="maxStorageQuota"
|
||||
label="{{ __('Max storage quota (GB)') }}"
|
||||
type="number"
|
||||
min="1"
|
||||
suffix="GB"
|
||||
hint="{{ __('When reached, new uploads are blocked.') }}"
|
||||
/>
|
||||
</div>
|
||||
<x-card :title="__('Storage')" variant="outlined">
|
||||
<x-input
|
||||
wire:model="maxStorageQuota"
|
||||
:label="__('Max storage quota (GB)')"
|
||||
type="number"
|
||||
min="1"
|
||||
suffix="GB"
|
||||
:hint="__('When reached, new uploads are blocked.')"
|
||||
/>
|
||||
</x-card>
|
||||
|
||||
<x-button type="submit" label="{{ __('Save Settings') }}" class="btn-primary w-full" icon="o-check" spinner="saveSettings" />
|
||||
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" class="w-full" data-test="save-settings" />
|
||||
</form>
|
||||
|
||||
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
|
||||
{{ __('The upload and download pages show the default mark again.') }}
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||||
<x-button :label="__('Remove')" danger wire:click="removeLogo" data-test="confirm-remove-logo" />
|
||||
</x-slot:actions>
|
||||
</x-modal>
|
||||
|
||||
<x-modal wire:model="confirmingPasswordRemoval" :title="__('Remove the system password?')" icon="lock_open">
|
||||
{{ __('Anyone who can reach the upload page can upload files again.') }}
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||||
<x-button :label="__('Remove')" danger wire:click="clearSystemPassword" data-test="confirm-clear-system-password" />
|
||||
</x-slot:actions>
|
||||
</x-modal>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user