Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2fe2c7c05 | ||
|
|
7073a81b99 | ||
|
|
e241c08ef6 | ||
|
|
efb8ee3898 | ||
|
|
0056a6034e | ||
|
|
fe68238143 | ||
|
|
2f422a5320 | ||
|
|
18e95bc653 | ||
|
|
942645f880 | ||
|
|
dc6dd989ed | ||
|
|
c71479d119 | ||
|
|
8db97cd60c | ||
|
|
afa2ee472e | ||
|
|
912c120299 | ||
|
|
cb98e67e04 | ||
|
|
01f5fa8e75 | ||
|
|
0fb4223bfc | ||
|
|
fd6646c1ee | ||
|
|
b018a6e290 | ||
|
|
6391e38dbf | ||
|
|
02ee378ec9 | ||
|
|
1eb7c56729 | ||
|
|
ddda4c9f1d | ||
|
|
cd6dfdbe30 | ||
|
|
828e2c4236 | ||
|
|
77d2a5e998 | ||
|
|
c4a7061e87 | ||
|
|
83d617a0a7 | ||
|
|
0a689bb11f | ||
|
|
5c51cc4c73 | ||
|
|
36a4353269 | ||
|
|
465f6fe5ee | ||
|
|
aa23914353 | ||
|
|
81e64a1c9a | ||
|
|
d0f72ac8a4 | ||
|
|
c6b7510f05 | ||
|
|
ab85c4a575 | ||
|
|
c5a14cb381 | ||
|
|
57a0578ec7 | ||
|
|
e988a39a12 | ||
|
|
2a8e52a330 | ||
|
|
80eeec44b8 | ||
|
|
f6be7c7dea | ||
|
|
df7561737c | ||
|
|
525607db99 | ||
|
|
98ce6037de | ||
|
|
be84b1a791 | ||
|
|
aa604d701e | ||
|
|
5fa45e992b | ||
|
|
c8408658c9 | ||
|
|
6de15d1398 | ||
|
|
a3ef18384b | ||
|
|
fd06211c72 | ||
|
|
87edfc1826 | ||
|
|
55458f3109 | ||
|
|
7546698a3c | ||
|
|
138a028537 | ||
|
|
66fd8b0e8a | ||
|
|
8404c6e40f | ||
|
|
7fcef01be4 | ||
|
|
af6ebe5730 | ||
|
|
21d7b6eba9 | ||
|
|
53a11ae0c7 | ||
|
|
5844aeb621 | ||
|
|
381d58003d | ||
|
|
31ee06fa3a | ||
|
|
cd2b593fcf | ||
|
|
7dc31371f6 | ||
|
|
6f8d0c7abf | ||
|
|
3c657127ac | ||
|
|
a49b22cf83 | ||
|
|
db5efbc834 | ||
|
|
36cc32674e | ||
|
|
34569840ae | ||
|
|
d359ac29b6 | ||
|
|
1cad54ce42 | ||
|
|
037c08498b | ||
|
|
1991f27ee8 | ||
|
|
9048dca8b4 | ||
|
|
0ee747f945 | ||
|
|
f1399517ed | ||
|
|
27e6071992 | ||
|
|
3c7361023c | ||
|
|
3e6b3bfc79 | ||
|
|
6a16aaca3a | ||
|
|
c4387cc03a | ||
|
|
6d5d257db1 | ||
|
|
da60f76fe9 | ||
|
|
80f70fe8c8 | ||
|
|
a413111fc0 | ||
|
|
250a6c7643 | ||
|
|
9dcd54b523 |
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+2
@@ -26,3 +26,5 @@ yarn-error.log
|
||||
*/.DS_Store
|
||||
**/.DS_Store
|
||||
/provision/
|
||||
/.scannerwork/
|
||||
/storage/logs
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: fluxui-development
|
||||
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Flux UI Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Flux UI patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.
|
||||
|
||||
Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
|
||||
|
||||
Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
|
||||
|
||||
<!-- Basic Button -->
|
||||
```blade
|
||||
<flux:button variant="primary">Click me</flux:button>
|
||||
```
|
||||
|
||||
## Available Components (Free Edition)
|
||||
|
||||
Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip
|
||||
|
||||
## Icons
|
||||
|
||||
Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
|
||||
|
||||
<!-- Icon Button -->
|
||||
```blade
|
||||
<flux:button icon="arrow-down-tray">Export</flux:button>
|
||||
```
|
||||
|
||||
For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
|
||||
|
||||
```bash
|
||||
php artisan flux:icon crown grip-vertical github
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Form Fields
|
||||
|
||||
<!-- Form Field -->
|
||||
```blade
|
||||
<flux:field>
|
||||
<flux:label>Email</flux:label>
|
||||
<flux:input type="email" wire:model="email" />
|
||||
<flux:error name="email" />
|
||||
</flux:field>
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
<!-- Modal -->
|
||||
```blade
|
||||
<flux:modal wire:model="showModal">
|
||||
<flux:heading>Title</flux:heading>
|
||||
<p>Content</p>
|
||||
</flux:modal>
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Check component renders correctly
|
||||
2. Test interactive states
|
||||
3. Verify mobile responsiveness
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Trying to use Pro-only components in the free edition
|
||||
- Not checking if a Flux component exists before creating custom implementations
|
||||
- Forgetting to use the `search-docs` tool for component-specific documentation
|
||||
- Not following existing project patterns for Flux usage
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: fortify-development
|
||||
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Laravel Fortify Development
|
||||
|
||||
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Laravel Fortify patterns and documentation.
|
||||
|
||||
## Usage
|
||||
|
||||
- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
|
||||
- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
|
||||
- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
|
||||
- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
|
||||
- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
|
||||
|
||||
## Available Features
|
||||
|
||||
Enable in `config/fortify.php` features array:
|
||||
|
||||
- `Features::registration()` - User registration
|
||||
- `Features::resetPasswords()` - Password reset via email
|
||||
- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
|
||||
- `Features::updateProfileInformation()` - Profile updates
|
||||
- `Features::updatePasswords()` - Password changes
|
||||
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
|
||||
- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys
|
||||
|
||||
> Use `search-docs` for feature configuration options and customization patterns.
|
||||
|
||||
## Setup Workflows
|
||||
|
||||
### Two-Factor Authentication Setup
|
||||
|
||||
```
|
||||
- [ ] Add TwoFactorAuthenticatable trait to User model
|
||||
- [ ] Enable feature in config/fortify.php
|
||||
- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
|
||||
- [ ] Set up view callbacks in FortifyServiceProvider
|
||||
- [ ] Create 2FA management UI
|
||||
- [ ] Test QR code and recovery codes
|
||||
```
|
||||
|
||||
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
|
||||
|
||||
### Passkeys Setup
|
||||
|
||||
```
|
||||
- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser
|
||||
- [ ] Enable passkeys feature in config/fortify.php
|
||||
- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
|
||||
- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable
|
||||
- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion
|
||||
```
|
||||
|
||||
> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm.
|
||||
|
||||
### Email Verification Setup
|
||||
|
||||
```
|
||||
- [ ] Enable emailVerification feature in config
|
||||
- [ ] Implement MustVerifyEmail interface on User model
|
||||
- [ ] Set up verifyEmailView callback
|
||||
- [ ] Add verified middleware to protected routes
|
||||
- [ ] Test verification email flow
|
||||
```
|
||||
|
||||
> Use `search-docs` for MustVerifyEmail implementation patterns.
|
||||
|
||||
### Password Reset Setup
|
||||
|
||||
```
|
||||
- [ ] Enable resetPasswords feature in config
|
||||
- [ ] Set up requestPasswordResetLinkView callback
|
||||
- [ ] Set up resetPasswordView callback
|
||||
- [ ] Define password.reset named route (if views disabled)
|
||||
- [ ] Test reset email and link flow
|
||||
```
|
||||
|
||||
> Use `search-docs` for custom password reset flow patterns.
|
||||
|
||||
### SPA Authentication Setup
|
||||
|
||||
```
|
||||
- [ ] Set 'views' => false in config/fortify.php
|
||||
- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
|
||||
- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
|
||||
- [ ] Set up CSRF token handling
|
||||
- [ ] Test XHR authentication flows
|
||||
```
|
||||
|
||||
> Use `search-docs` for integration and SPA authentication patterns.
|
||||
|
||||
#### Two-Factor Authentication in SPA Mode
|
||||
|
||||
When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
|
||||
|
||||
If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
|
||||
|
||||
```json
|
||||
{
|
||||
"two_factor": true
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Custom Authentication Logic
|
||||
|
||||
Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
|
||||
|
||||
### Registration Customization
|
||||
|
||||
Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
|
||||
|
||||
## Key Endpoints
|
||||
|
||||
| Feature | Method | Endpoint |
|
||||
|------------------------|----------|---------------------------------------------|
|
||||
| Login | POST | `/login` |
|
||||
| Logout | POST | `/logout` |
|
||||
| Register | POST | `/register` |
|
||||
| Password Reset Request | POST | `/forgot-password` |
|
||||
| Password Reset | POST | `/reset-password` |
|
||||
| Email Verify Notice | GET | `/email/verify` |
|
||||
| Resend Verification | POST | `/email/verification-notification` |
|
||||
| Password Confirm | POST | `/user/confirm-password` |
|
||||
| Enable 2FA | POST | `/user/two-factor-authentication` |
|
||||
| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
|
||||
| 2FA Challenge | POST | `/two-factor-challenge` |
|
||||
| Get QR Code | GET | `/user/two-factor-qr-code` |
|
||||
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
|
||||
| Passkey Login Options | GET | `/passkeys/login/options` |
|
||||
| Passkey Login | POST | `/passkeys/login` |
|
||||
| Passkey Confirm Options| GET | `/passkeys/confirm/options` |
|
||||
| Passkey Confirm | POST | `/passkeys/confirm` |
|
||||
| Passkey Options | GET | `/user/passkeys/options` |
|
||||
| Register Passkey | POST | `/user/passkeys` |
|
||||
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
||||
@@ -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,141 @@
|
||||
# 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.
|
||||
31. UI kit (Flux): Flux components vs custom components vs another library.
|
||||
- Hint: grep `<flux:` in `resources/views`.
|
||||
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.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: laravel-best-practices
|
||||
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Laravel Best Practices
|
||||
|
||||
Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
||||
|
||||
## Consistency First
|
||||
|
||||
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
||||
|
||||
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
||||
|
||||
## How to Apply
|
||||
|
||||
1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out.
|
||||
2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files.
|
||||
3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job.
|
||||
4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable.
|
||||
5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them.
|
||||
6. Re-read the diff against every mapped rule before finishing.
|
||||
|
||||
## Rule Index
|
||||
|
||||
Cross-cutting changes often need more than one rule file.
|
||||
|
||||
| Concern | Read |
|
||||
| --- | --- |
|
||||
| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) |
|
||||
| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) |
|
||||
| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) |
|
||||
| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) |
|
||||
| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) |
|
||||
| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) |
|
||||
| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) |
|
||||
| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) |
|
||||
| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) |
|
||||
| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) |
|
||||
| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) |
|
||||
| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) |
|
||||
| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) |
|
||||
| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) |
|
||||
| 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) |
|
||||
| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
|
||||
| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
|
||||
|
||||
## Decision Rules
|
||||
|
||||
- Prefer framework features and existing application abstractions over new helpers or dependencies.
|
||||
- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable.
|
||||
- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Advanced Query Patterns
|
||||
|
||||
## Use `addSelect()` Subqueries for Single Values from Has-Many
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
public function scopeWithLastLoginAt($query): void
|
||||
{
|
||||
$query->addSelect([
|
||||
'last_login_at' => Login::select('created_at')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1),
|
||||
])->withCasts(['last_login_at' => 'datetime']);
|
||||
}
|
||||
```
|
||||
|
||||
## Create Dynamic Relationships via Subquery FK
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
public function lastLogin(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Login::class);
|
||||
}
|
||||
|
||||
public function scopeWithLastLogin($query): void
|
||||
{
|
||||
$query->addSelect([
|
||||
'last_login_id' => Login::select('id')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1),
|
||||
])->with('lastLogin');
|
||||
}
|
||||
```
|
||||
|
||||
## Use Conditional Aggregates Instead of Multiple Count Queries
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
$statuses = Feature::toBase()
|
||||
->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
|
||||
->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
|
||||
->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
|
||||
->first();
|
||||
```
|
||||
|
||||
## Use `setRelation()` to Prevent Circular N+1
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
$feature->load('comments.user');
|
||||
$feature->comments->each->setRelation('feature', $feature);
|
||||
```
|
||||
|
||||
## Prefer `whereIn` + Subquery Over `whereHas`
|
||||
|
||||
`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.
|
||||
|
||||
Incorrect (correlated EXISTS re-executes per row):
|
||||
|
||||
```php
|
||||
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
|
||||
```
|
||||
|
||||
Correct (index-friendly subquery, no PHP memory overhead):
|
||||
|
||||
```php
|
||||
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
|
||||
```
|
||||
|
||||
## Sometimes Two Simple Queries Beat 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.
|
||||
|
||||
## Use Compound Indexes Matching `orderBy` Column Order
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
// Migration
|
||||
$table->index(['last_name', 'first_name']);
|
||||
|
||||
// Query — column order must match the index
|
||||
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
|
||||
```
|
||||
|
||||
## Use Correlated Subqueries 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.
|
||||
|
||||
```php
|
||||
public function scopeOrderByLastLogin($query): void
|
||||
{
|
||||
$query->orderByDesc(Login::select('created_at')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1)
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,202 @@
|
||||
# Architecture Best Practices
|
||||
|
||||
## Single-Purpose Action Classes
|
||||
|
||||
Extract discrete business operations into invokable Action classes.
|
||||
|
||||
```php
|
||||
class CreateOrderAction
|
||||
{
|
||||
public function __construct(private InventoryService $inventory) {}
|
||||
|
||||
public function handle(array $data): Order
|
||||
{
|
||||
$order = Order::create($data);
|
||||
$this->inventory->reserve($order);
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Use Dependency Injection
|
||||
|
||||
Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function store(StoreOrderRequest $request)
|
||||
{
|
||||
$service = app(OrderService::class);
|
||||
|
||||
return $service->create($request->validated());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(private OrderService $service) {}
|
||||
|
||||
public function store(StoreOrderRequest $request)
|
||||
{
|
||||
return $this->service->create($request->validated());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Code to Interfaces
|
||||
|
||||
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
|
||||
|
||||
Incorrect (concrete dependency):
|
||||
```php
|
||||
class OrderService
|
||||
{
|
||||
public function __construct(private StripeGateway $gateway) {}
|
||||
}
|
||||
```
|
||||
|
||||
Correct (interface dependency):
|
||||
```php
|
||||
interface PaymentGateway
|
||||
{
|
||||
public function charge(int $amount, string $customerId): PaymentResult;
|
||||
}
|
||||
|
||||
class OrderService
|
||||
{
|
||||
public function __construct(private PaymentGateway $gateway) {}
|
||||
}
|
||||
```
|
||||
|
||||
Bind in a service provider:
|
||||
|
||||
```php
|
||||
$this->app->bind(PaymentGateway::class, StripeGateway::class);
|
||||
```
|
||||
|
||||
## Default Sort by Descending
|
||||
|
||||
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::paginate();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$posts = Post::latest()->paginate();
|
||||
```
|
||||
|
||||
## Use Atomic Locks for Race Conditions
|
||||
|
||||
Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
|
||||
|
||||
```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();
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
strlen('José'); // 5 (bytes, not characters)
|
||||
strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
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'
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
Incorrect (job overhead for trivial work):
|
||||
```php
|
||||
dispatch(new LogPageView($page));
|
||||
```
|
||||
|
||||
Correct (runs after response, same 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 `Context` for Request-Scoped Data
|
||||
|
||||
The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
|
||||
|
||||
```php
|
||||
// In middleware
|
||||
Context::add('tenant_id', $request->header('X-Tenant-ID'));
|
||||
|
||||
// Anywhere later — controllers, jobs, log context
|
||||
$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`.
|
||||
|
||||
## Use `Concurrency::run()` for Parallel Execution
|
||||
|
||||
Run independent operations in parallel using child processes — no async libraries needed.
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Concurrency;
|
||||
|
||||
[$users, $orders] = Concurrency::run([
|
||||
fn () => User::count(),
|
||||
fn () => Order::where('status', 'pending')->count(),
|
||||
]);
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Convention Over Configuration
|
||||
|
||||
Follow Laravel conventions. Don't override defaults unnecessarily.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class Customer extends Model
|
||||
{
|
||||
protected $table = 'Customer';
|
||||
protected $primaryKey = 'customer_id';
|
||||
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class Customer extends Model
|
||||
{
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# Blade & Views Best Practices
|
||||
|
||||
## Use `$attributes->merge()` in Component Templates
|
||||
|
||||
Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
|
||||
|
||||
```blade
|
||||
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
|
||||
{{ $message }}
|
||||
</div>
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## Prefer Blade Components Over `@include`
|
||||
|
||||
`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
|
||||
|
||||
## Use View Composers for Shared View Data
|
||||
|
||||
If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
|
||||
|
||||
## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
|
||||
|
||||
A single view can return either the full page or just a fragment, keeping routing clean.
|
||||
|
||||
```php
|
||||
return view('dashboard', compact('users'))
|
||||
->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
|
||||
```
|
||||
|
||||
## Use `@aware` for Deeply Nested Component Props
|
||||
|
||||
Avoids re-passing parent props through every level of nested components.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Caching Best Practices
|
||||
|
||||
## Use `Cache::remember()` Instead of Manual Get/Put
|
||||
|
||||
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$val = Cache::get('stats');
|
||||
if (! $val) {
|
||||
$val = $this->computeStats();
|
||||
Cache::put('stats', $val, 60);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$val = Cache::remember('stats', 60, fn () => $this->computeStats());
|
||||
```
|
||||
|
||||
## Use `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.
|
||||
|
||||
Incorrect: `Cache::remember('users', 300, fn () => User::all());`
|
||||
|
||||
Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
|
||||
|
||||
## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
|
||||
|
||||
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.
|
||||
|
||||
`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
|
||||
|
||||
## 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`.
|
||||
|
||||
```php
|
||||
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.
|
||||
|
||||
Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
|
||||
|
||||
Correct: `Cache::add('lock', true, 10);`
|
||||
|
||||
## Use `once()` for Per-Request Memoization
|
||||
|
||||
`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
|
||||
public function roles(): Collection
|
||||
{
|
||||
return once(fn () => $this->loadRoles());
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Configure Failover Cache Stores in Production
|
||||
|
||||
If Redis goes down, the app falls back to a secondary store automatically.
|
||||
|
||||
```php
|
||||
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# Collection Best Practices
|
||||
|
||||
## Use Higher-Order Messages for Simple Operations
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users->each(function (User $user) {
|
||||
$user->markAsVip();
|
||||
});
|
||||
```
|
||||
|
||||
Correct: `$users->each->markAsVip();`
|
||||
|
||||
Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
|
||||
|
||||
## Choose `cursor()` vs. `lazy()` Correctly
|
||||
|
||||
- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
|
||||
- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
|
||||
|
||||
Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
|
||||
|
||||
Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
|
||||
|
||||
## 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.
|
||||
|
||||
## Use `toQuery()` for Bulk Operations on Collections
|
||||
|
||||
Avoids manual `whereIn` construction.
|
||||
|
||||
Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
|
||||
|
||||
Correct: `$users->toQuery()->update([...]);`
|
||||
|
||||
## Use `#[CollectedBy]` for Custom Collection Classes
|
||||
|
||||
More declarative than overriding `newCollection()`.
|
||||
|
||||
```php
|
||||
#[CollectedBy(UserCollection::class)]
|
||||
class User extends Model {}
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
# Configuration Best Practices
|
||||
|
||||
## `env()` Only in Config Files
|
||||
|
||||
Direct `env()` calls may return `null` when config is cached.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$key = env('API_KEY');
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
// config/services.php
|
||||
'key' => env('API_KEY'),
|
||||
|
||||
// Application code
|
||||
$key = config('services.key');
|
||||
```
|
||||
|
||||
## Use Encrypted Env or External Secrets
|
||||
|
||||
Never store production secrets in plain `.env` files in version control.
|
||||
|
||||
Incorrect:
|
||||
```bash
|
||||
|
||||
# .env committed to repo or shared in Slack
|
||||
|
||||
STRIPE_SECRET=sk_live_abc123
|
||||
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
|
||||
```
|
||||
|
||||
Correct:
|
||||
```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.
|
||||
|
||||
## 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
|
||||
|
||||
Use class constants instead of hardcoded magic strings for model states, types, and statuses.
|
||||
|
||||
```php
|
||||
// Incorrect
|
||||
return $this->type === 'normal';
|
||||
|
||||
// Correct
|
||||
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.
|
||||
|
||||
```php
|
||||
// Only when lang files already exist in the project
|
||||
return back()->with('message', __('app.article_added'));
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
# Database Performance Best Practices
|
||||
|
||||
## Always Eager Load Relationships
|
||||
|
||||
Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
|
||||
|
||||
Incorrect (N+1 — executes 1 + N queries):
|
||||
```php
|
||||
$posts = Post::all();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->author->name;
|
||||
}
|
||||
```
|
||||
|
||||
Correct (2 queries total):
|
||||
```php
|
||||
$posts = Post::with('author')->get();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->author->name;
|
||||
}
|
||||
```
|
||||
|
||||
Constrain eager loads to select only needed columns (always include the foreign key):
|
||||
|
||||
```php
|
||||
$users = User::with(['posts' => function ($query) {
|
||||
$query->select('id', 'user_id', 'title')
|
||||
->where('published', true)
|
||||
->latest()
|
||||
->limit(10);
|
||||
}])->get();
|
||||
```
|
||||
|
||||
## Prevent Lazy Loading in Development
|
||||
|
||||
Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
|
||||
|
||||
```php
|
||||
public function boot(): void
|
||||
{
|
||||
Model::preventLazyLoading(! app()->isProduction());
|
||||
}
|
||||
```
|
||||
|
||||
Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
|
||||
|
||||
## Select Only Needed Columns
|
||||
|
||||
Avoid `SELECT *` — especially when tables have large text or JSON columns.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::with('author')->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```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.
|
||||
|
||||
## Chunk Large Datasets
|
||||
|
||||
Never load thousands of records at once. Use chunking for batch processing.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users = User::all();
|
||||
foreach ($users as $user) {
|
||||
$user->notify(new WeeklyDigest);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
User::where('subscribed', true)->chunk(200, function ($users) {
|
||||
foreach ($users as $user) {
|
||||
$user->notify(new WeeklyDigest);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
|
||||
|
||||
```php
|
||||
User::where('active', false)->chunkById(200, function ($users) {
|
||||
$users->each->delete();
|
||||
});
|
||||
```
|
||||
|
||||
## Add Database Indexes
|
||||
|
||||
Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
|
||||
|
||||
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')->index()->constrained();
|
||||
$table->string('status')->index();
|
||||
$table->timestamps();
|
||||
$table->index(['status', 'created_at']);
|
||||
});
|
||||
```
|
||||
|
||||
Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
|
||||
|
||||
## Use `withCount()` for Counting Relations
|
||||
|
||||
Never load entire collections just to count them.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::all();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->comments->count();
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$posts = Post::withCount('comments')->get();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->comments_count;
|
||||
}
|
||||
```
|
||||
|
||||
Conditional counting:
|
||||
|
||||
```php
|
||||
$posts = Post::withCount([
|
||||
'comments',
|
||||
'comments as approved_comments_count' => function ($query) {
|
||||
$query->where('approved', true);
|
||||
},
|
||||
])->get();
|
||||
```
|
||||
|
||||
## Use `cursor()` for Memory-Efficient Iteration
|
||||
|
||||
For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users = User::where('active', true)->get();
|
||||
```
|
||||
|
||||
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:
|
||||
```php
|
||||
// Controller
|
||||
$users = User::with('profile')->get();
|
||||
return view('users.index', compact('users'));
|
||||
```
|
||||
|
||||
```blade
|
||||
@foreach ($users as $user)
|
||||
{{ $user->profile->name }}
|
||||
@endforeach
|
||||
```
|
||||
@@ -0,0 +1,150 @@
|
||||
# Eloquent Best Practices
|
||||
|
||||
## Use Correct Relationship Types
|
||||
|
||||
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
|
||||
|
||||
```php
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
```
|
||||
|
||||
## Use Local Scopes for Reusable Queries
|
||||
|
||||
Extract reusable query constraints into local scopes to avoid duplication.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
|
||||
$articles = Article::whereHas('user', function ($q) {
|
||||
$q->where('verified', true)->whereNotNull('activated_at');
|
||||
})->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
#[Scope]
|
||||
protected function active(Builder $query): Builder
|
||||
{
|
||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||
}
|
||||
|
||||
// Usage
|
||||
$active = User::active()->get();
|
||||
$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
|
||||
```
|
||||
|
||||
## Apply Global Scopes Sparingly
|
||||
|
||||
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):
|
||||
```php
|
||||
class PublishedScope implements Scope
|
||||
{
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$builder->where('published', true);
|
||||
}
|
||||
}
|
||||
// Now admin panels, reports, and background jobs all silently skip drafts
|
||||
```
|
||||
|
||||
Correct (local scope you opt into):
|
||||
```php
|
||||
#[Scope]
|
||||
protected function published(Builder $query): Builder
|
||||
{
|
||||
return $query->where('published', true);
|
||||
}
|
||||
|
||||
Post::published()->paginate(); // Explicit
|
||||
Post::paginate(); // Admin sees all
|
||||
```
|
||||
|
||||
## Define Attribute Casts
|
||||
|
||||
Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
|
||||
|
||||
```php
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Cast Date Columns Properly
|
||||
|
||||
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'ordered_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
```blade
|
||||
{{ $order->ordered_at->toDateString() }}
|
||||
{{ $order->ordered_at->format('m-d') }}
|
||||
```
|
||||
|
||||
## Use `whereBelongsTo()` for Relationship Queries
|
||||
|
||||
Cleaner than manually specifying foreign keys.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Post::where('user_id', $user->id)->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
Post::whereBelongsTo($user)->get();
|
||||
Post::whereBelongsTo($user, 'author')->get();
|
||||
```
|
||||
|
||||
## Avoid Hardcoded Table Names in Queries
|
||||
|
||||
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).
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
DB::table('users')->where('active', true)->get();
|
||||
|
||||
$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();
|
||||
|
||||
// Even better — use Eloquent or the query builder instead of raw SQL
|
||||
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.
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Error Handling Best Practices
|
||||
|
||||
## Exception Reporting and Rendering
|
||||
|
||||
There are two valid approaches — choose one and apply it consistently across the project.
|
||||
|
||||
**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
|
||||
|
||||
```php
|
||||
class InvalidOrderException extends Exception
|
||||
{
|
||||
public function report(): void { /* custom reporting */ }
|
||||
|
||||
public function render(Request $request): Response
|
||||
{
|
||||
return response()->view('errors.invalid-order', status: 422);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
|
||||
|
||||
```php
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
$exceptions->report(function (InvalidOrderException $e) { /* ... */ });
|
||||
$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.
|
||||
|
||||
## Use `ShouldntReport` for Exceptions That Should Never Log
|
||||
|
||||
More discoverable than listing classes in `dontReport()`.
|
||||
|
||||
```php
|
||||
class PodcastProcessingException extends Exception implements ShouldntReport {}
|
||||
```
|
||||
|
||||
## Throttle High-Volume Exceptions
|
||||
|
||||
A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
|
||||
|
||||
## Enable `dontReportDuplicates()`
|
||||
|
||||
Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
|
||||
|
||||
## Force JSON Error Rendering for API Routes
|
||||
|
||||
Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
|
||||
|
||||
```php
|
||||
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||
return $request->is('api/*') || $request->expectsJson();
|
||||
});
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
```php
|
||||
class InvalidOrderException extends Exception
|
||||
{
|
||||
public function context(): array
|
||||
{
|
||||
return ['order_id' => $this->orderId];
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# Events & Notifications Best Practices
|
||||
|
||||
## Rely on Event Discovery
|
||||
|
||||
Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
|
||||
|
||||
## Run `event:cache` in Production Deploy
|
||||
|
||||
Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
|
||||
|
||||
## Use `ShouldDispatchAfterCommit` Inside Transactions
|
||||
|
||||
Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
|
||||
|
||||
```php
|
||||
class OrderShipped implements ShouldDispatchAfterCommit {}
|
||||
```
|
||||
|
||||
## Always Queue Notifications
|
||||
|
||||
Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
|
||||
|
||||
```php
|
||||
class InvoicePaid extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
}
|
||||
```
|
||||
|
||||
## Use `afterCommit()` on Notifications in Transactions
|
||||
|
||||
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
|
||||
|
||||
```php
|
||||
$user->notify((new InvoicePaid($invoice))->afterCommit());
|
||||
```
|
||||
|
||||
## Route Notification Channels to Dedicated Queues
|
||||
|
||||
Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
|
||||
|
||||
## Use On-Demand Notifications for Non-User Recipients
|
||||
|
||||
Avoid creating dummy models to send notifications to arbitrary addresses.
|
||||
|
||||
```php
|
||||
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.
|
||||
@@ -0,0 +1,160 @@
|
||||
# HTTP Client Best Practices
|
||||
|
||||
## Always Set Explicit Timeouts
|
||||
|
||||
The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users');
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$response = Http::timeout(5)
|
||||
->connectTimeout(3)
|
||||
->get('https://api.example.com/users');
|
||||
```
|
||||
|
||||
For service-specific clients, define timeouts in a macro:
|
||||
|
||||
```php
|
||||
Http::macro('github', function () {
|
||||
return Http::baseUrl('https://api.github.com')
|
||||
->timeout(10)
|
||||
->connectTimeout(3)
|
||||
->withToken(config('services.github.token'));
|
||||
});
|
||||
|
||||
$response = Http::github()->get('/repos/laravel/framework');
|
||||
```
|
||||
|
||||
## Use Retry with Backoff for External APIs
|
||||
|
||||
External APIs have transient failures. Use `retry()` with increasing delays.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::post('https://api.stripe.com/v1/charges', $data);
|
||||
|
||||
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);
|
||||
```
|
||||
|
||||
Only retry on specific errors:
|
||||
|
||||
```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');
|
||||
```
|
||||
|
||||
## Handle Errors Explicitly
|
||||
|
||||
The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users/1');
|
||||
$user = $response->json(); // Could be an error body
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$response = Http::timeout(5)
|
||||
->get('https://api.example.com/users/1')
|
||||
->throw();
|
||||
|
||||
$user = $response->json();
|
||||
```
|
||||
|
||||
For graceful degradation:
|
||||
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users/1');
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
if ($response->notFound()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response->throw();
|
||||
```
|
||||
|
||||
## Use Request Pooling for Concurrent Requests
|
||||
|
||||
When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
|
||||
|
||||
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'),
|
||||
]);
|
||||
|
||||
$users = $responses['users']->json();
|
||||
$posts = $responses['posts']->json();
|
||||
```
|
||||
|
||||
## Fake HTTP Calls in Tests
|
||||
|
||||
Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
|
||||
|
||||
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 () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
Http::fake([
|
||||
'api.example.com/users/1' => Http::response([
|
||||
'name' => 'John Doe',
|
||||
'email' => 'john@example.com',
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new UserSyncService;
|
||||
$service->sync(1);
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->url() === 'https://api.example.com/users/1';
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Test failure scenarios too:
|
||||
|
||||
```php
|
||||
Http::fake([
|
||||
'api.example.com/*' => Http::failedConnection(),
|
||||
]);
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# Mail Best Practices
|
||||
|
||||
## Implement `ShouldQueue` on the Mailable Class
|
||||
|
||||
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.
|
||||
|
||||
## Use `afterCommit()` on Mailables Inside Transactions
|
||||
|
||||
A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
|
||||
|
||||
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
||||
|
||||
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
|
||||
|
||||
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
||||
|
||||
Correct: `Mail::assertQueued(OrderShipped::class);`
|
||||
|
||||
## Use Markdown Mailables for Transactional Emails
|
||||
|
||||
Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
|
||||
|
||||
## Separate Content Tests from Sending Tests
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Migration Best Practices
|
||||
|
||||
## Generate Migrations with Artisan
|
||||
|
||||
Always use `php artisan make:migration` for consistent naming and timestamps.
|
||||
|
||||
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
|
||||
|
||||
Automatic naming and referential integrity.
|
||||
|
||||
```php
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
// Non-standard names
|
||||
$table->foreignId('author_id')->constrained('users');
|
||||
```
|
||||
|
||||
## Never Modify Deployed Migrations
|
||||
|
||||
Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
|
||||
|
||||
Incorrect (editing a deployed migration):
|
||||
```php
|
||||
// 2024_01_01_create_posts_table.php — already in production
|
||||
$table->string('slug')->unique(); // ← added after deployment
|
||||
```
|
||||
|
||||
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');
|
||||
});
|
||||
```
|
||||
|
||||
## Add Indexes in the Migration
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
// Migration
|
||||
$table->string('status')->default('pending');
|
||||
|
||||
// Model
|
||||
protected $attributes = [
|
||||
'status' => 'pending',
|
||||
];
|
||||
```
|
||||
|
||||
## Write Reversible `down()` Methods by Default
|
||||
|
||||
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.
|
||||
|
||||
## 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']);
|
||||
```
|
||||
@@ -0,0 +1,144 @@
|
||||
# Queue & Job Best Practices
|
||||
|
||||
## Set `retry_after` Greater Than `timeout`
|
||||
|
||||
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.
|
||||
|
||||
Incorrect (`retry_after` ≤ `timeout`):
|
||||
```php
|
||||
class ProcessReport implements ShouldQueue
|
||||
{
|
||||
public $timeout = 120;
|
||||
}
|
||||
|
||||
// config/queue.php — retry_after: 90 ← job retried while still running!
|
||||
```
|
||||
|
||||
Correct (`retry_after` > `timeout`):
|
||||
```php
|
||||
class ProcessReport implements ShouldQueue
|
||||
{
|
||||
public $timeout = 120;
|
||||
}
|
||||
|
||||
// config/queue.php — retry_after: 180 ← safely longer than any job timeout
|
||||
```
|
||||
|
||||
## Use Exponential Backoff
|
||||
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
Correct (exponential backoff):
|
||||
```php
|
||||
class SyncWithStripe implements ShouldQueue
|
||||
{
|
||||
public $tries = 3;
|
||||
public $backoff = [1, 5, 10];
|
||||
}
|
||||
```
|
||||
|
||||
## Implement `ShouldBeUnique`
|
||||
|
||||
Prevent duplicate job processing.
|
||||
|
||||
```php
|
||||
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return $this->order->id;
|
||||
}
|
||||
|
||||
public $uniqueFor = 3600;
|
||||
}
|
||||
```
|
||||
|
||||
## Always Implement `failed()`
|
||||
|
||||
Handle errors explicitly — don't rely on silent failure.
|
||||
|
||||
```php
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
$this->podcast->update(['status' => 'failed']);
|
||||
Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit External API Calls in Jobs
|
||||
|
||||
Use `RateLimited` middleware to throttle jobs calling third-party APIs.
|
||||
|
||||
```php
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new RateLimited('external-api')];
|
||||
}
|
||||
```
|
||||
|
||||
## Batch Related Jobs
|
||||
|
||||
Use `Bus::batch()` when jobs should succeed or fail together.
|
||||
|
||||
```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();
|
||||
```
|
||||
|
||||
## `retryUntil()` Needs `$tries = 0`
|
||||
|
||||
When using time-based retry limits, set `$tries = 0` to avoid premature failure.
|
||||
|
||||
```php
|
||||
public $tries = 0;
|
||||
|
||||
public function retryUntil(): \DateTimeInterface
|
||||
{
|
||||
return now()->addHours(4);
|
||||
}
|
||||
```
|
||||
|
||||
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
|
||||
|
||||
`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,
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
@@ -0,0 +1,99 @@
|
||||
# Routing & Controllers Best Practices
|
||||
|
||||
## Use Implicit Route Model Binding
|
||||
|
||||
Let Laravel resolve models automatically from route parameters.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function show(int $id)
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function show(Post $post)
|
||||
{
|
||||
return view('posts.show', ['post' => $post]);
|
||||
}
|
||||
```
|
||||
|
||||
## Use Scoped Bindings for Nested Resources
|
||||
|
||||
Enforce parent-child relationships automatically.
|
||||
|
||||
```php
|
||||
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
|
||||
// $post is automatically scoped to $user
|
||||
})->scopeBindings();
|
||||
```
|
||||
|
||||
## Use Resource Controllers
|
||||
|
||||
Use `Route::resource()` or `apiResource()` for RESTful endpoints.
|
||||
|
||||
```php
|
||||
Route::resource('posts', PostController::class);
|
||||
// In routes/api.php — the /api prefix is applied automatically
|
||||
Route::apiResource('posts', Api\PostController::class);
|
||||
```
|
||||
|
||||
## Keep Controllers Thin
|
||||
|
||||
Aim for under 10 lines per method. Extract business logic to action or service classes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([...]);
|
||||
if ($request->hasFile('image')) {
|
||||
$request->file('image')->move(public_path('images'));
|
||||
}
|
||||
$post = Post::create($validated);
|
||||
$post->tags()->sync($validated['tags']);
|
||||
event(new PostCreated($post));
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function store(StorePostRequest $request, CreatePostAction $create)
|
||||
{
|
||||
$post = $create->execute($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');
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# Task Scheduling Best Practices
|
||||
|
||||
## Use `withoutOverlapping()` on Variable-Duration Tasks
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
Schedule::command('billing:charge')->monthly()->environments(['production']);
|
||||
```
|
||||
|
||||
## Use `takeUntilTimeout()` for Time-Bounded Processing
|
||||
|
||||
A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
|
||||
|
||||
## Use Schedule Groups for Shared Configuration
|
||||
|
||||
Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
|
||||
|
||||
```php
|
||||
Schedule::daily()
|
||||
->onOneServer()
|
||||
->timezone('America/New_York')
|
||||
->group(function () {
|
||||
Schedule::command('emails:send --force');
|
||||
Schedule::command('emails:prune');
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,198 @@
|
||||
# Security Best Practices
|
||||
|
||||
## Mass Assignment Protection
|
||||
|
||||
Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class User extends Model
|
||||
{
|
||||
protected $guarded = []; // All fields are mass assignable
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class User extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Never use `$guarded = []` on models that accept user input.
|
||||
|
||||
## Authorize Every Action
|
||||
|
||||
Use policies or gates in controllers. Never skip authorization.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function update(UpdatePostRequest $request, Post $post)
|
||||
{
|
||||
$post->update($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function update(UpdatePostRequest $request, Post $post)
|
||||
{
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$post->update($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
Or via Form Request:
|
||||
|
||||
```php
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('update', $this->route('post'));
|
||||
}
|
||||
```
|
||||
|
||||
## Prevent SQL Injection
|
||||
|
||||
Always use parameter binding. Never interpolate user input into queries.
|
||||
|
||||
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();
|
||||
```
|
||||
|
||||
## Escape Output to Prevent XSS
|
||||
|
||||
Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
{!! $user->bio !!}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```blade
|
||||
{{ $user->bio }}
|
||||
```
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
<form method="POST" action="/posts">
|
||||
<input type="text" name="title">
|
||||
</form>
|
||||
```
|
||||
|
||||
Correct:
|
||||
```blade
|
||||
<form method="POST" action="/posts">
|
||||
@csrf
|
||||
<input type="text" name="title">
|
||||
</form>
|
||||
```
|
||||
|
||||
## Rate Limit Auth and API Routes
|
||||
|
||||
Apply `throttle` middleware to authentication and API routes.
|
||||
|
||||
```php
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->ip());
|
||||
});
|
||||
|
||||
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
||||
```
|
||||
|
||||
## Validate File Uploads
|
||||
|
||||
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
|
||||
|
||||
```php
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Store with generated filenames:
|
||||
|
||||
```php
|
||||
$path = $request->file('avatar')->store('avatars', 'public');
|
||||
```
|
||||
|
||||
## Keep Secrets Out of 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');
|
||||
```
|
||||
|
||||
## Audit Dependencies
|
||||
|
||||
Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
|
||||
|
||||
```bash
|
||||
composer audit
|
||||
```
|
||||
|
||||
## Encrypt Sensitive Database Fields
|
||||
|
||||
Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class Integration extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_key' => 'string',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class Integration extends Model
|
||||
{
|
||||
protected $hidden = ['api_key', 'api_secret'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_key' => 'encrypted',
|
||||
'api_secret' => 'encrypted',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,125 @@
|
||||
# Conventions & Style
|
||||
|
||||
## Follow Laravel 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 Shorter Readable Syntax
|
||||
|
||||
| Verbose | Shorter |
|
||||
|---------|---------|
|
||||
| `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')` |
|
||||
|
||||
## Use Laravel String & Array Helpers
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
$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()`.
|
||||
|
||||
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');
|
||||
```
|
||||
|
||||
Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`.
|
||||
|
||||
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%"
|
||||
```
|
||||
|
||||
URIs — use `Uri` for URL manipulation:
|
||||
```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.
|
||||
|
||||
Use `search-docs` for the full list of available methods — these helpers are extensive.
|
||||
|
||||
## No Inline JS/CSS in Blade
|
||||
|
||||
Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
let article = `{{ json_encode($article) }}`;
|
||||
```
|
||||
|
||||
Correct:
|
||||
```blade
|
||||
<button class="js-fav-article" data-article='@json($article)'>{{ $article->name }}</button>
|
||||
```
|
||||
|
||||
Pass data to JS via data attributes or use a dedicated PHP-to-JS package.
|
||||
|
||||
## No Unnecessary Comments
|
||||
|
||||
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.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
// Check if there are any joins
|
||||
if (count((array) $builder->getQuery()->joins) > 0)
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
if ($this->hasJoins())
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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();
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# Validation & Forms Best Practices
|
||||
|
||||
## Use Form Request Classes
|
||||
|
||||
Extract validation from controllers into dedicated Form Request classes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'required|max:255',
|
||||
'body' => 'required',
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function store(StorePostRequest $request)
|
||||
{
|
||||
Post::create($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
## Array vs. String Notation for Rules
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
// Preferred for new code
|
||||
'email' => ['required', 'email', Rule::unique('users')],
|
||||
|
||||
// Follow existing convention if the project uses string notation
|
||||
'email' => 'required|email|unique:users',
|
||||
```
|
||||
|
||||
## Always Use `validated()`
|
||||
|
||||
Get only validated data. Never use `$request->all()` for mass operations.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Post::create($request->all());
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
Post::create($request->validated());
|
||||
```
|
||||
|
||||
## Use `Rule::when()` for Conditional Validation
|
||||
|
||||
```php
|
||||
'company_name' => [
|
||||
Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
|
||||
],
|
||||
```
|
||||
|
||||
## Use the `after()` Method for Custom Validation
|
||||
|
||||
Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
|
||||
|
||||
```php
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator) {
|
||||
if ($this->quantity > Product::find($this->product_id)?->stock) {
|
||||
$validator->errors()->add('quantity', 'Not enough stock.');
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: livewire-development
|
||||
description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Livewire Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Livewire 4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### Converting Between Formats
|
||||
|
||||
Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.
|
||||
|
||||
### Choosing a Component Format
|
||||
|
||||
> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established.
|
||||
|
||||
Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored.
|
||||
|
||||
### Component Format Reference
|
||||
|
||||
| Format | Flag | Class Path | View Path |
|
||||
|--------|------|------------|-----------|
|
||||
| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) |
|
||||
| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` |
|
||||
| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` |
|
||||
| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
|
||||
| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) |
|
||||
|
||||
> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above.
|
||||
|
||||
Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`.
|
||||
|
||||
### Single-File Component Example
|
||||
|
||||
<!-- Single-File Component Example -->
|
||||
```php
|
||||
<?php
|
||||
use Livewire\Component;
|
||||
|
||||
new class extends Component {
|
||||
public int $count = 0;
|
||||
|
||||
public function increment(): void
|
||||
{
|
||||
$this->count++;
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<div>
|
||||
<button wire:click="increment">Count: @{{ $count }}</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Livewire 4 Specifics
|
||||
|
||||
### Key Changes From Livewire 3
|
||||
|
||||
These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.
|
||||
|
||||
- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`.
|
||||
- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
|
||||
- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
|
||||
- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.
|
||||
|
||||
### New Features
|
||||
|
||||
- Component formats: single-file (SFC), multi-file (MFC), view-based components.
|
||||
- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
|
||||
- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.
|
||||
|
||||
| Feature | Usage | Purpose |
|
||||
|---------|-------|---------|
|
||||
| Islands | `@island(name: 'stats')` | Isolated update regions |
|
||||
| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
|
||||
| Deferred | `defer` attribute | Load after page render |
|
||||
| Bundled | `lazy.bundle` | Load multiple together |
|
||||
|
||||
### New Directives
|
||||
|
||||
- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
|
||||
- `data-loading` attribute automatically added to elements triggering network requests.
|
||||
|
||||
| Directive | Purpose |
|
||||
|-----------|---------|
|
||||
| `wire:sort` | Drag-and-drop sorting |
|
||||
| `wire:intersect` | Viewport intersection detection |
|
||||
| `wire:ref` | Element references for JS |
|
||||
| `.renderless` | Component without rendering |
|
||||
| `.preserve-scroll` | Preserve scroll position |
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Always use `wire:key` in loops
|
||||
- Use `wire:loading` for loading states
|
||||
- Use `wire:model.live` for instant updates (default is debounced)
|
||||
- Validate and authorize in actions (treat like HTTP requests)
|
||||
|
||||
## Configuration
|
||||
|
||||
- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.
|
||||
|
||||
## Alpine & JavaScript
|
||||
|
||||
- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
|
||||
- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.
|
||||
|
||||
For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Testing Example -->
|
||||
```php
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1);
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Browser console: Check for JS errors
|
||||
2. Network tab: Verify Livewire requests return 200
|
||||
3. Ensure `wire:key` on all `@foreach` loops
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Missing `wire:key` in loops → unexpected re-rendering
|
||||
- Expecting `wire:model` real-time → use `wire:model.live`
|
||||
- Unclosed component tags → syntax errors in v4
|
||||
- Using deprecated config keys or JS hooks
|
||||
- Including Alpine.js separately (already bundled in Livewire 4)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Livewire 4 JavaScript Integration
|
||||
|
||||
## Interceptor System (v4)
|
||||
|
||||
### Intercept Messages
|
||||
|
||||
```js
|
||||
Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => {
|
||||
onFinish(() => { /* After response, before processing */ });
|
||||
onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ });
|
||||
onError(() => { /* Server errors */ });
|
||||
});
|
||||
```
|
||||
|
||||
### Intercept Requests
|
||||
|
||||
```js
|
||||
Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => {
|
||||
onResponse(({ response }) => { /* When received */ });
|
||||
onSuccess(({ response, responseJson }) => { /* Success */ });
|
||||
onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ });
|
||||
onFailure(({ error }) => { /* Network failures */ });
|
||||
});
|
||||
```
|
||||
|
||||
### Component-Scoped Interceptors
|
||||
|
||||
```blade
|
||||
<script>
|
||||
this.$intercept('save', ({ component, onSuccess }) => {
|
||||
onSuccess(() => console.log('Saved!'));
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Magic Properties
|
||||
|
||||
- `$errors` - Access validation errors from JavaScript
|
||||
- `$intercept` - Component-scoped interceptors
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
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`
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||
|
||||
## Tailwind CSS v4 Specifics
|
||||
|
||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
|
||||
### CSS-First Configuration
|
||||
|
||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||
|
||||
<!-- CSS-First Config -->
|
||||
```css
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
```
|
||||
|
||||
### Import Syntax
|
||||
|
||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```diff
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
```
|
||||
|
||||
### Replaced Utilities
|
||||
|
||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------|-------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use `gap` utilities instead of margins for spacing between siblings:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</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.)
|
||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||
- Using margins for spacing between siblings instead of gap utilities
|
||||
- Forgetting to add dark mode variants when the project uses dark mode
|
||||
@@ -1,6 +0,0 @@
|
||||
projectKey=Helpdesk
|
||||
serverUrl=http://127.0.0.1:9001
|
||||
serverVersion=9.9.8.100196
|
||||
dashboardUrl=http://127.0.0.1:9001/dashboard?id=Helpdesk
|
||||
ceTaskId=AaA4s_Is8O8WM0gRYMf8
|
||||
ceTaskUrl=http://127.0.0.1:9001/api/ce/task?id=AaA4s_Is8O8WM0gRYMf8
|
||||
@@ -0,0 +1,168 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application running on PHP 8.3. 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.
|
||||
|
||||
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
|
||||
|
||||
This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
## Tools
|
||||
|
||||
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
|
||||
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
|
||||
- Use `database-schema` to inspect table structure before writing migrations or models.
|
||||
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
|
||||
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
|
||||
|
||||
## 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.
|
||||
- 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`.
|
||||
|
||||
### Search Syntax
|
||||
|
||||
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
|
||||
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
|
||||
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.
|
||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||
|
||||
## Tinker
|
||||
|
||||
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||
- Use array shape type definitions in PHPDoc blocks.
|
||||
|
||||
=== deployments rules ===
|
||||
|
||||
# Deployment
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# 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.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
|
||||
|
||||
## APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- 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`.
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
# Livewire
|
||||
|
||||
- Livewire allow 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.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## 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.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'Documentation',
|
||||
'storage_disk' => env('DOCUMENTATION_DISK', 'public'),
|
||||
'upload_path' => env('DOCUMENTATION_UPLOAD_PATH', 'documentation'),
|
||||
'max_upload_kb' => (int) env('DOCUMENTATION_MAX_UPLOAD_KB', 20480),
|
||||
'max_markdown_import_bytes' => (int) env('DOCUMENTATION_MAX_MARKDOWN_IMPORT_BYTES', 1048576),
|
||||
'import_markdown_attachments' => true,
|
||||
'allowed_upload_mimes' => [
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'txt',
|
||||
'csv',
|
||||
'md',
|
||||
'markdown',
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'webp',
|
||||
'zip',
|
||||
],
|
||||
'statuses' => [
|
||||
'draft' => 'Draft',
|
||||
'published' => 'Published',
|
||||
'archived' => 'Archived',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,517 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Documentation\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Documentation\Models\DocumentationCategory;
|
||||
use Modules\Documentation\Models\DocumentationDocument;
|
||||
use SplFileInfo;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Throwable;
|
||||
|
||||
final class ImportDocumentationCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The console command signature.
|
||||
*/
|
||||
protected $signature = 'helpdesk:import-documentation
|
||||
|
||||
{path? : Directory containing Markdown files; defaults to the bundled Sentinel documentation}
|
||||
|
||||
{--category=Sentinel Documentation : Documentation category name}
|
||||
|
||||
{--fresh : Remove previously imported documents in this category before importing}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Import Markdown documentation into the Helpdesk Documentation module.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
|
||||
$path = $this->resolvePath();
|
||||
|
||||
$categoryName = trim((string) $this->option('category'));
|
||||
|
||||
if ($categoryName === '') {
|
||||
|
||||
$this->components->error('The documentation category name cannot be empty.');
|
||||
|
||||
return self::FAILURE;
|
||||
|
||||
}
|
||||
|
||||
if (! is_dir($path)) {
|
||||
|
||||
$this->components->error("Documentation directory does not exist: {$path}");
|
||||
|
||||
return self::FAILURE;
|
||||
|
||||
}
|
||||
|
||||
if (! is_readable($path)) {
|
||||
|
||||
$this->components->error("Documentation directory is not readable: {$path}");
|
||||
|
||||
return self::FAILURE;
|
||||
|
||||
}
|
||||
|
||||
$files = $this->markdownFiles($path);
|
||||
|
||||
if ($files === []) {
|
||||
|
||||
$this->components->warn("No Markdown files were found in: {$path}");
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
$category = DB::transaction(function () use ($categoryName): DocumentationCategory {
|
||||
|
||||
$category = DocumentationCategory::query()->firstOrCreate(
|
||||
|
||||
['slug' => Str::slug($categoryName)],
|
||||
|
||||
[
|
||||
|
||||
'name' => $categoryName,
|
||||
|
||||
'description' => 'Imported documentation.',
|
||||
|
||||
'sort_order' => 0,
|
||||
|
||||
'is_active' => true,
|
||||
|
||||
],
|
||||
|
||||
);
|
||||
|
||||
if ($category->name !== $categoryName) {
|
||||
|
||||
$category->name = $categoryName;
|
||||
|
||||
$category->save();
|
||||
|
||||
}
|
||||
|
||||
if ($this->option('fresh')) {
|
||||
|
||||
/*
|
||||
|
||||
* Force-delete because DocumentationDocument uses SoftDeletes.
|
||||
|
||||
* Otherwise the unique slug constraint can prevent the same
|
||||
|
||||
* documentation set being imported again.
|
||||
|
||||
*/
|
||||
|
||||
$category->documents()
|
||||
|
||||
->withTrashed()
|
||||
|
||||
->get()
|
||||
|
||||
->each(static function (DocumentationDocument $document): void {
|
||||
|
||||
$document->forceDelete();
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
return $category;
|
||||
|
||||
});
|
||||
|
||||
$progress = $this->output->createProgressBar(count($files));
|
||||
|
||||
$progress->start();
|
||||
|
||||
$imported = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
|
||||
$this->importFile($category, $file, $path);
|
||||
|
||||
$imported++;
|
||||
|
||||
$progress->advance();
|
||||
|
||||
}
|
||||
|
||||
$progress->finish();
|
||||
|
||||
$this->newLine(2);
|
||||
|
||||
$this->components->info(
|
||||
|
||||
sprintf(
|
||||
|
||||
'Imported %d documentation %s into "%s".',
|
||||
|
||||
$imported,
|
||||
|
||||
$imported === 1 ? 'page' : 'pages',
|
||||
|
||||
$category->name,
|
||||
|
||||
),
|
||||
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
} catch (Throwable $exception) {
|
||||
|
||||
$this->newLine();
|
||||
|
||||
$this->components->error(
|
||||
|
||||
'Documentation import failed: '.$exception->getMessage(),
|
||||
|
||||
);
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
|
||||
$this->newLine();
|
||||
|
||||
$this->line($exception->getTraceAsString());
|
||||
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the requested documentation source directory.
|
||||
*/
|
||||
private function resolvePath(): string
|
||||
{
|
||||
|
||||
$argument = $this->argument('path');
|
||||
|
||||
if (is_string($argument) && trim($argument) !== '') {
|
||||
|
||||
$path = trim($argument);
|
||||
|
||||
if (! str_starts_with($path, DIRECTORY_SEPARATOR)) {
|
||||
|
||||
$path = base_path($path);
|
||||
|
||||
}
|
||||
|
||||
return rtrim($path, DIRECTORY_SEPARATOR);
|
||||
|
||||
}
|
||||
|
||||
return module_path('Documentation', 'ImportData/docs');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Markdown files recursively.
|
||||
|
||||
*
|
||||
|
||||
* @return list<SplFileInfo>
|
||||
*/
|
||||
private function markdownFiles(string $path): array
|
||||
{
|
||||
|
||||
$finder = Finder::create()
|
||||
|
||||
->files()
|
||||
|
||||
->in($path)
|
||||
|
||||
->name('*.md')
|
||||
|
||||
->name('*.markdown')
|
||||
|
||||
->sortByName();
|
||||
|
||||
/** @var list<SplFileInfo> $files */
|
||||
$files = iterator_to_array($finder, false);
|
||||
|
||||
return $files;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Import one Markdown document.
|
||||
*/
|
||||
private function importFile(
|
||||
|
||||
DocumentationCategory $category,
|
||||
|
||||
SplFileInfo $file,
|
||||
|
||||
string $rootPath,
|
||||
|
||||
): void {
|
||||
|
||||
$realPath = $file->getRealPath();
|
||||
|
||||
if ($realPath === false || ! is_readable($realPath)) {
|
||||
|
||||
throw new \RuntimeException(
|
||||
sprintf('Unable to read documentation file: %s', $file->getPathname()),
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
$body = file_get_contents($realPath);
|
||||
|
||||
if ($body === false) {
|
||||
|
||||
throw new \RuntimeException(
|
||||
sprintf('Unable to read documentation file: %s', $realPath),
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
$body = $this->stripBom($body);
|
||||
|
||||
$relativePath = ltrim(
|
||||
|
||||
str_replace(
|
||||
|
||||
DIRECTORY_SEPARATOR,
|
||||
|
||||
'/',
|
||||
|
||||
substr($realPath, strlen(rtrim($rootPath, DIRECTORY_SEPARATOR))),
|
||||
|
||||
),
|
||||
|
||||
'/',
|
||||
|
||||
);
|
||||
|
||||
$title = $this->extractTitle($body, $file);
|
||||
|
||||
$summary = $this->extractSummary($body);
|
||||
|
||||
$slug = $this->makeSlug($relativePath, $title);
|
||||
|
||||
DocumentationDocument::withTrashed()->updateOrCreate(
|
||||
|
||||
['slug' => $slug],
|
||||
|
||||
[
|
||||
|
||||
'documentation_category_id' => $category->id,
|
||||
|
||||
'title' => $title,
|
||||
|
||||
'summary' => $summary,
|
||||
|
||||
'body' => $body,
|
||||
|
||||
'status' => DocumentationDocument::STATUS_PUBLISHED,
|
||||
|
||||
'is_featured' => strtolower($file->getBasename('.'.$file->getExtension())) === 'index',
|
||||
|
||||
'version' => '1.0.0',
|
||||
|
||||
'reference' => Str::limit($relativePath, 100, ''),
|
||||
|
||||
'published_at' => now(),
|
||||
|
||||
'deleted_at' => null,
|
||||
|
||||
],
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first Markdown H1 heading.
|
||||
*/
|
||||
private function extractTitle(string $body, SplFileInfo $file): string
|
||||
{
|
||||
|
||||
if (preg_match('/^\s*#\s+(.+?)\s*$/m', $body, $matches) === 1) {
|
||||
|
||||
$title = trim($matches[1]);
|
||||
|
||||
$title = preg_replace('/\s+#+\s*$/', '', $title) ?? $title;
|
||||
|
||||
$title = trim($title);
|
||||
|
||||
if ($title !== '') {
|
||||
|
||||
return Str::limit($title, 220, '');
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$filename = pathinfo($file->getFilename(), PATHINFO_FILENAME);
|
||||
|
||||
return Str::limit(
|
||||
|
||||
Str::headline(str_replace(['-', '_'], ' ', $filename)),
|
||||
|
||||
220,
|
||||
|
||||
'',
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a useful summary from the first normal Markdown paragraph.
|
||||
*/
|
||||
private function extractSummary(string $body): ?string
|
||||
{
|
||||
|
||||
$lines = preg_split('/\R/', $body);
|
||||
|
||||
if (! is_array($lines)) {
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
$paragraph = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
|
||||
$line = trim($line);
|
||||
|
||||
if ($line === '') {
|
||||
|
||||
if ($paragraph !== []) {
|
||||
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
str_starts_with($line, '#')
|
||||
|
||||
|| str_starts_with($line, '```')
|
||||
|
||||
|| str_starts_with($line, '~~~')
|
||||
|
||||
|| str_starts_with($line, '>')
|
||||
|
||||
|| preg_match('/^[-*+]\s+/', $line) === 1
|
||||
|
||||
|| preg_match('/^\d+\.\s+/', $line) === 1
|
||||
|
||||
) {
|
||||
|
||||
if ($paragraph !== []) {
|
||||
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
|
||||
$paragraph[] = $line;
|
||||
|
||||
}
|
||||
|
||||
if ($paragraph === []) {
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
$summary = implode(' ', $paragraph);
|
||||
|
||||
$summary = preg_replace('/!$begin:math:display$\(\[\^$end:math:display$]*)\]$begin:math:text$\[\^\)\]\+$end:math:text$/', '$1', $summary) ?? $summary;
|
||||
|
||||
$summary = preg_replace('/$begin:math:display$\(\[\^$end:math:display$]+)\]$begin:math:text$\[\^\)\]\+$end:math:text$/', '$1', $summary) ?? $summary;
|
||||
|
||||
$summary = str_replace(['**', '__', '`', '*', '_'], '', $summary);
|
||||
|
||||
$summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary);
|
||||
|
||||
return $summary === ''
|
||||
|
||||
? null
|
||||
|
||||
: Str::limit($summary, 500, '');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a stable globally unique slug from the source path.
|
||||
*/
|
||||
private function makeSlug(string $relativePath, string $title): string
|
||||
{
|
||||
|
||||
$withoutExtension = preg_replace(
|
||||
|
||||
'/\.(md|markdown)$/i',
|
||||
|
||||
'',
|
||||
|
||||
$relativePath,
|
||||
|
||||
) ?? $relativePath;
|
||||
|
||||
$slug = Str::slug(str_replace('/', '-', $withoutExtension));
|
||||
|
||||
if ($slug === '') {
|
||||
|
||||
$slug = Str::slug($title);
|
||||
|
||||
}
|
||||
|
||||
if ($slug === '') {
|
||||
|
||||
$slug = 'document-'.substr(sha1($relativePath), 0, 12);
|
||||
|
||||
}
|
||||
|
||||
return Str::limit($slug, 240, '');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a UTF-8 byte-order mark.
|
||||
*/
|
||||
private function stripBom(string $contents): string
|
||||
{
|
||||
|
||||
if (str_starts_with($contents, "\xEF\xBB\xBF")) {
|
||||
|
||||
return substr($contents, 3);
|
||||
|
||||
}
|
||||
|
||||
return $contents;
|
||||
|
||||
}
|
||||
}
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('documentation_categories')) {
|
||||
Schema::create('documentation_categories', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name', 150);
|
||||
$table->string('slug', 180)->unique();
|
||||
$table->string('icon', 80)->nullable();
|
||||
$table->string('colour', 32)->default('blue');
|
||||
$table->text('description')->nullable();
|
||||
$table->unsignedInteger('sort_order')->default(0)->index();
|
||||
$table->boolean('is_active')->default(true)->index();
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('documentation_documents')) {
|
||||
Schema::create('documentation_documents', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('documentation_category_id')->nullable()->constrained('documentation_categories')->nullOnDelete();
|
||||
$table->string('title', 220);
|
||||
$table->string('slug', 240)->unique();
|
||||
$table->string('summary', 500)->nullable();
|
||||
$table->longText('body')->nullable();
|
||||
$table->string('status', 40)->default('draft')->index();
|
||||
$table->boolean('is_featured')->default(false)->index();
|
||||
$table->string('version', 50)->default('1.0.0');
|
||||
$table->string('reference', 100)->nullable()->index();
|
||||
$table->string('attachment_disk')->nullable();
|
||||
$table->string('attachment_path')->nullable();
|
||||
$table->string('attachment_name')->nullable();
|
||||
$table->string('attachment_mime')->nullable();
|
||||
$table->unsignedBigInteger('attachment_size')->nullable();
|
||||
$table->timestamp('published_at')->nullable()->index();
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('documentation_attachments')) {
|
||||
Schema::create('documentation_attachments', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('documentation_document_id')->constrained('documentation_documents')->cascadeOnDelete();
|
||||
$table->string('disk')->default('public');
|
||||
$table->string('path');
|
||||
$table->string('original_name');
|
||||
$table->string('mime_type')->nullable();
|
||||
$table->unsignedBigInteger('size')->nullable();
|
||||
$table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('documentation_attachments');
|
||||
Schema::dropIfExists('documentation_documents');
|
||||
Schema::dropIfExists('documentation_categories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Documentation\Http\Controllers;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Documentation\Http\Requests\StoreDocumentationCategoryRequest;
|
||||
use Modules\Documentation\Http\Requests\UpdateDocumentationCategoryRequest;
|
||||
use Modules\Documentation\Models\DocumentationCategory;
|
||||
|
||||
class DocumentationCategoryController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display category list.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
return view('documentation::categories.index', [
|
||||
'categories' => DocumentationCategory::query()->withCount('documents')->ordered()->paginate(15),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create form.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('documentation::categories.create', [
|
||||
'category' => new DocumentationCategory(['colour' => 'emerald', 'is_active' => true]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a category.
|
||||
*/
|
||||
public function store(StoreDocumentationCategoryRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['is_active'] = $request->boolean('is_active', true);
|
||||
$data['created_by'] = $request->user()?->id;
|
||||
$data['updated_by'] = $request->user()?->id;
|
||||
|
||||
DocumentationCategory::query()->create($data);
|
||||
|
||||
return redirect()->route('documentation.categories.index')->with('status', 'Category created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit form.
|
||||
*/
|
||||
public function edit(DocumentationCategory $category): View
|
||||
{
|
||||
return view('documentation::categories.edit', [
|
||||
'category' => $category,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a category.
|
||||
*/
|
||||
public function update(UpdateDocumentationCategoryRequest $request, DocumentationCategory $category): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$data['updated_by'] = $request->user()?->id;
|
||||
|
||||
$category->update($data);
|
||||
|
||||
return redirect()->route('documentation.categories.index')->with('status', 'Category updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a category.
|
||||
*/
|
||||
public function destroy(DocumentationCategory $category): RedirectResponse
|
||||
{
|
||||
$category->delete();
|
||||
|
||||
return redirect()->route('documentation.categories.index')->with('status', 'Category deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Documentation\Http\Controllers;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Documentation\Http\Requests\StoreDocumentationDocumentRequest;
|
||||
use Modules\Documentation\Http\Requests\UpdateDocumentationDocumentRequest;
|
||||
use Modules\Documentation\Models\DocumentationAttachment;
|
||||
use Modules\Documentation\Models\DocumentationCategory;
|
||||
use Modules\Documentation\Models\DocumentationDocument;
|
||||
|
||||
class DocumentationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the documentation dashboard and document list.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = DocumentationDocument::query()->with(['category', 'attachments'])->latest();
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->string('status')->toString());
|
||||
}
|
||||
|
||||
if ($request->filled('category')) {
|
||||
$query->where('documentation_category_id', $request->integer('category'));
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = '%'.$request->string('search')->trim()->toString().'%';
|
||||
$query->where(function ($builder) use ($search): void {
|
||||
$builder->where('title', 'like', $search)
|
||||
->orWhere('summary', 'like', $search)
|
||||
->orWhere('reference', 'like', $search)
|
||||
->orWhere('body', 'like', $search);
|
||||
});
|
||||
}
|
||||
|
||||
return view('documentation::documents.index', [
|
||||
'documents' => $query->paginate(15)->withQueryString(),
|
||||
'categories' => DocumentationCategory::query()->ordered()->get(),
|
||||
'featuredDocuments' => DocumentationDocument::query()->with('category')->featured()->latest()->limit(4)->get(),
|
||||
'stats' => [
|
||||
'total' => DocumentationDocument::query()->count(),
|
||||
'published' => DocumentationDocument::query()->published()->count(),
|
||||
'drafts' => DocumentationDocument::query()->where('status', DocumentationDocument::STATUS_DRAFT)->count(),
|
||||
'categories' => DocumentationCategory::query()->count(),
|
||||
'archived' => DocumentationDocument::onlyTrashed()->count(),
|
||||
],
|
||||
'statuses' => config('documentation.statuses', []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display soft-deleted documents.
|
||||
*/
|
||||
public function archived(Request $request): View
|
||||
{
|
||||
$query = DocumentationDocument::onlyTrashed()
|
||||
->with(['category', 'attachments'])
|
||||
->latest('deleted_at');
|
||||
|
||||
if ($request->filled('category')) {
|
||||
$query->where('documentation_category_id', $request->integer('category'));
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = '%'.$request->string('search')->trim()->toString().'%';
|
||||
|
||||
$query->where(function ($builder) use ($search): void {
|
||||
$builder->where('title', 'like', $search)
|
||||
->orWhere('summary', 'like', $search)
|
||||
->orWhere('reference', 'like', $search)
|
||||
->orWhere('body', 'like', $search);
|
||||
});
|
||||
}
|
||||
|
||||
return view('documentation::documents.archived', [
|
||||
'documents' => $query->paginate(15)->withQueryString(),
|
||||
'categories' => DocumentationCategory::query()->ordered()->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the create document form.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('documentation::documents.create', [
|
||||
'document' => new DocumentationDocument(['status' => DocumentationDocument::STATUS_DRAFT, 'version' => '1.0.0']),
|
||||
'categories' => DocumentationCategory::query()->active()->ordered()->get(),
|
||||
'statuses' => config('documentation.statuses', []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created document.
|
||||
*/
|
||||
public function store(StoreDocumentationDocumentRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['is_featured'] = $request->boolean('is_featured');
|
||||
$data['created_by'] = $request->user()?->id;
|
||||
$data['updated_by'] = $request->user()?->id;
|
||||
|
||||
if ($request->hasFile('attachment')) {
|
||||
if (blank($data['body'] ?? null)) {
|
||||
$data['body'] = $this->extractMarkdownBody($request) ?: ($data['body'] ?? null);
|
||||
}
|
||||
|
||||
$data = array_merge($data, $this->storeAttachment($request));
|
||||
}
|
||||
|
||||
unset($data['attachment'], $data['attachments']);
|
||||
|
||||
$document = DocumentationDocument::query()->create($data);
|
||||
$this->storeAttachments($request, $document);
|
||||
|
||||
return redirect()->route('documentation.documents.show', $document)->with('status', 'Document created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a document.
|
||||
*/
|
||||
public function show(DocumentationDocument $document): View
|
||||
{
|
||||
return view('documentation::documents.show', [
|
||||
'document' => $document->load(['category', 'attachments']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the edit document form.
|
||||
*/
|
||||
public function edit(DocumentationDocument $document): View
|
||||
{
|
||||
return view('documentation::documents.edit', [
|
||||
'document' => $document->load('attachments'),
|
||||
'categories' => DocumentationCategory::query()->active()->ordered()->get(),
|
||||
'statuses' => config('documentation.statuses', []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing document.
|
||||
*/
|
||||
public function update(UpdateDocumentationDocumentRequest $request, DocumentationDocument $document): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['is_featured'] = $request->boolean('is_featured');
|
||||
$data['updated_by'] = $request->user()?->id;
|
||||
|
||||
if ($request->boolean('remove_attachment')) {
|
||||
$this->deleteAttachment($document);
|
||||
$data = array_merge($data, $this->emptyAttachmentData());
|
||||
}
|
||||
|
||||
if ($request->hasFile('attachment')) {
|
||||
$this->deleteAttachment($document);
|
||||
|
||||
if (blank($data['body'] ?? null)) {
|
||||
$data['body'] = $this->extractMarkdownBody($request) ?: ($data['body'] ?? null);
|
||||
}
|
||||
|
||||
$data = array_merge($data, $this->storeAttachment($request));
|
||||
}
|
||||
|
||||
unset($data['attachment'], $data['attachments'], $data['remove_attachment']);
|
||||
$document->update($data);
|
||||
$this->storeAttachments($request, $document);
|
||||
|
||||
return redirect()->route('documentation.documents.show', $document)->with('status', 'Document updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a document using Laravel soft deletes.
|
||||
*
|
||||
* Stored files and attachment records are deliberately retained. They are
|
||||
* only removed when an archived document is permanently deleted.
|
||||
*/
|
||||
public function destroy(Request $request, DocumentationDocument $document): RedirectResponse
|
||||
{
|
||||
$document->forceFill([
|
||||
'status' => DocumentationDocument::STATUS_ARCHIVED,
|
||||
'updated_by' => $request->user()?->id,
|
||||
])->save();
|
||||
|
||||
$document->delete();
|
||||
|
||||
return redirect()
|
||||
->route('documentation.documents.index')
|
||||
->with('status', 'Document archived successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a previously archived document.
|
||||
*/
|
||||
public function restore(Request $request, int $document): RedirectResponse
|
||||
{
|
||||
$document = DocumentationDocument::onlyTrashed()->findOrFail($document);
|
||||
|
||||
$document->restore();
|
||||
|
||||
// Restored documents return to Draft so they cannot unexpectedly
|
||||
// become publicly/operationally published again.
|
||||
$document->forceFill([
|
||||
'status' => DocumentationDocument::STATUS_DRAFT,
|
||||
'updated_by' => $request->user()?->id,
|
||||
])->save();
|
||||
|
||||
return redirect()
|
||||
->route('documentation.documents.archived')
|
||||
->with('status', 'Document restored as a draft.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete an archived document and all stored files.
|
||||
*/
|
||||
public function forceDestroy(int $document): RedirectResponse
|
||||
{
|
||||
$document = DocumentationDocument::onlyTrashed()
|
||||
->with('attachments')
|
||||
->findOrFail($document);
|
||||
|
||||
$this->deleteAttachment($document);
|
||||
|
||||
foreach ($document->attachments as $attachment) {
|
||||
Storage::disk($attachment->disk)->delete($attachment->path);
|
||||
$attachment->delete();
|
||||
}
|
||||
|
||||
$document->forceDelete();
|
||||
|
||||
return redirect()
|
||||
->route('documentation.documents.archived')
|
||||
->with('status', 'Document permanently deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the attached document file.
|
||||
*/
|
||||
public function download(DocumentationDocument $document)
|
||||
{
|
||||
abort_if(blank($document->attachment_path), 404);
|
||||
|
||||
return Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public'))
|
||||
->download($document->attachment_path, $document->attachment_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download one of the document's additional attachments.
|
||||
*/
|
||||
public function downloadAttachment(DocumentationDocument $document, DocumentationAttachment $attachment)
|
||||
{
|
||||
abort_if($attachment->documentation_document_id !== $document->id, 404);
|
||||
|
||||
return Storage::disk($attachment->disk)->download($attachment->path, $attachment->original_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one of the document's additional attachments.
|
||||
*/
|
||||
public function destroyAttachment(DocumentationDocument $document, DocumentationAttachment $attachment): RedirectResponse
|
||||
{
|
||||
abort_if($attachment->documentation_document_id !== $document->id, 404);
|
||||
|
||||
Storage::disk($attachment->disk)->delete($attachment->path);
|
||||
$attachment->delete();
|
||||
|
||||
return redirect()->route('documentation.documents.edit', $document)->with('status', 'Attachment removed successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract body content from an uploaded Markdown file when requested.
|
||||
*/
|
||||
private function extractMarkdownBody(Request $request): ?string
|
||||
{
|
||||
if (! config('documentation.import_markdown_attachments', true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$file = $request->file('attachment');
|
||||
|
||||
if ($file === null || ! $this->isMarkdownUpload($request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($file->getSize() > (int) config('documentation.max_markdown_import_bytes', 1048576)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getRealPath());
|
||||
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the uploaded attachment is a Markdown file.
|
||||
*/
|
||||
private function isMarkdownUpload(Request $request): bool
|
||||
{
|
||||
$file = $request->file('attachment');
|
||||
|
||||
if ($file === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
$mime = strtolower((string) $file->getMimeType());
|
||||
|
||||
return $extension === 'md' || in_array($mime, ['text/markdown', 'text/x-markdown'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an uploaded attachment and return persisted metadata.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function storeAttachment(Request $request): array
|
||||
{
|
||||
$file = $request->file('attachment');
|
||||
$disk = config('documentation.storage_disk', 'public');
|
||||
$path = $file->store(config('documentation.upload_path', 'documentation'), $disk);
|
||||
|
||||
return [
|
||||
'attachment_disk' => $disk,
|
||||
'attachment_path' => $path,
|
||||
'attachment_name' => $file->getClientOriginalName(),
|
||||
'attachment_mime' => $file->getMimeType(),
|
||||
'attachment_size' => $file->getSize(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Store all additional uploaded attachments for a document.
|
||||
*/
|
||||
private function storeAttachments(Request $request, DocumentationDocument $document): void
|
||||
{
|
||||
if (! $request->hasFile('attachments')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$disk = config('documentation.storage_disk', 'public');
|
||||
|
||||
foreach ((array) $request->file('attachments') as $file) {
|
||||
if ($file === null || ! $file->isValid()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->store(config('documentation.upload_path', 'documentation'), $disk);
|
||||
|
||||
$document->attachments()->create([
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
'uploaded_by' => $request->user()?->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a stored attachment if it exists.
|
||||
*/
|
||||
private function deleteAttachment(DocumentationDocument $document): void
|
||||
{
|
||||
if (filled($document->attachment_path)) {
|
||||
Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public'))->delete($document->attachment_path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty attachment metadata.
|
||||
*
|
||||
* @return array<string, null>
|
||||
*/
|
||||
private function emptyAttachmentData(): array
|
||||
{
|
||||
return [
|
||||
'attachment_disk' => null,
|
||||
'attachment_path' => null,
|
||||
'attachment_name' => null,
|
||||
'attachment_mime' => null,
|
||||
'attachment_size' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Documentation\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreDocumentationCategoryRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine whether the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:150'],
|
||||
'slug' => ['nullable', 'string', 'max:180', Rule::unique('documentation_categories', 'slug')],
|
||||
'icon' => ['nullable', 'string', 'max:80'],
|
||||
'colour' => ['required', 'string', 'max:32'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Documentation\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Documentation\Models\DocumentationDocument;
|
||||
|
||||
class StoreDocumentationDocumentRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine whether the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'documentation_category_id' => ['nullable', 'integer', 'exists:documentation_categories,id'],
|
||||
'title' => ['required', 'string', 'max:220'],
|
||||
'slug' => ['nullable', 'string', 'max:240', Rule::unique('documentation_documents', 'slug')],
|
||||
'summary' => ['nullable', 'string', 'max:500'],
|
||||
'body' => ['nullable', 'string'],
|
||||
'status' => ['required', Rule::in([DocumentationDocument::STATUS_DRAFT, DocumentationDocument::STATUS_PUBLISHED, DocumentationDocument::STATUS_ARCHIVED])],
|
||||
'is_featured' => ['nullable', 'boolean'],
|
||||
'version' => ['required', 'string', 'max:50'],
|
||||
'reference' => ['nullable', 'string', 'max:100'],
|
||||
'attachment' => ['nullable', 'file', 'max:'.config('documentation.max_upload_kb', 20480), 'mimes:'.implode(',', config('documentation.allowed_upload_mimes', []))],
|
||||
'attachments' => ['nullable', 'array'],
|
||||
'attachments.*' => ['file', 'max:'.config('documentation.max_upload_kb', 20480), 'mimes:'.implode(',', config('documentation.allowed_upload_mimes', []))],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Documentation\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Documentation\Models\DocumentationCategory;
|
||||
|
||||
class UpdateDocumentationCategoryRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine whether the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var DocumentationCategory|null $category */
|
||||
$category = $this->route('category');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:150'],
|
||||
'slug' => ['nullable', 'string', 'max:180', Rule::unique('documentation_categories', 'slug')->ignore($category?->id)],
|
||||
'icon' => ['nullable', 'string', 'max:80'],
|
||||
'colour' => ['required', 'string', 'max:32'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Documentation\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Documentation\Models\DocumentationDocument;
|
||||
|
||||
class UpdateDocumentationDocumentRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine whether the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var DocumentationDocument|null $document */
|
||||
$document = $this->route('document');
|
||||
|
||||
return [
|
||||
'documentation_category_id' => ['nullable', 'integer', 'exists:documentation_categories,id'],
|
||||
'title' => ['required', 'string', 'max:220'],
|
||||
'slug' => ['nullable', 'string', 'max:240', Rule::unique('documentation_documents', 'slug')->ignore($document?->id)],
|
||||
'summary' => ['nullable', 'string', 'max:500'],
|
||||
'body' => ['nullable', 'string'],
|
||||
'status' => ['required', Rule::in([DocumentationDocument::STATUS_DRAFT, DocumentationDocument::STATUS_PUBLISHED, DocumentationDocument::STATUS_ARCHIVED])],
|
||||
'is_featured' => ['nullable', 'boolean'],
|
||||
'version' => ['required', 'string', 'max:50'],
|
||||
'reference' => ['nullable', 'string', 'max:100'],
|
||||
'attachment' => ['nullable', 'file', 'max:'.config('documentation.max_upload_kb', 20480), 'mimes:'.implode(',', config('documentation.allowed_upload_mimes', []))],
|
||||
'attachments' => ['nullable', 'array'],
|
||||
'attachments.*' => ['file', 'max:'.config('documentation.max_upload_kb', 20480), 'mimes:'.implode(',', config('documentation.allowed_upload_mimes', []))],
|
||||
'remove_attachment' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
### Sentinel API Development
|
||||
|
||||
This document details the overall development techniques for effective api development
|
||||
that makes the Sentinel product much more accessible and scalable.
|
||||
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
### AS400 HTTP Server Documentation
|
||||
|
||||
To copy an Apache configuration file using an AS400 console, you'll need to use the IBM i operating system's file management tools. This involves creating a member for your HTTP server in a specific library, then using the COPY command to duplicate the configuration file.
|
||||
You can then edit the new member using the Data File Utility (DFU).
|
||||
|
||||
Here's a step-by-step guide:
|
||||
|
||||
1. Create the HTTP Server Member:
|
||||
Go to the QUSRSYS/QATMHINSTC library using the WRKMBRPDM command.
|
||||
Choose option 3 next to APACHEDFT and press F4 to prompt the COPYcommand.
|
||||
Change the TOMBR (To member) parameter to a unique name for your new HTTP server (e.g., MYAPACHE). This name cannot exceed 10 characters.
|
||||
Press Enter to accept the changes.
|
||||
|
||||
2. Edit the New Member:
|
||||
Type 18 next to the newly created member name (e.g., MYAPACHE) and press Enter.
|
||||
Page down once in the DFU.
|
||||
Modify the Instance Data value, ensuring it reflects the new configuration you want to use. For example:
|
||||
Change -apache -d /www/apachedft -f conf/httpd.conf -AutoStartN to a similar line that points to your new configuration files.
|
||||
Remember to update the paths to your configuration files (e.g., /www/MYAPACHE/conf/httpd.conf).
|
||||
|
||||
3. Create Directories and Copy Files:
|
||||
Use the 5250 command line to create the necessary directories for your new server instance:
|
||||
MKDIR '/www/MYAPACHE'
|
||||
MKDIR '/www/MYAPACHE/conf'
|
||||
MKDIR '/www/MYAPACHE/logs'
|
||||
MKDIR '/www/MYAPACHE/htdocs'
|
||||
Copy the configuration files from the source system to your new server instance using the COPY command or by using the IBM i's file management tools.
|
||||
|
||||
4. Configure the httpd.conf File (if necessary):
|
||||
Open the httpd.conf file (or apache2.conf) in a text editor.
|
||||
Make any necessary changes to the configuration, such as modifying paths, ports, or other settings as per your requirements.
|
||||
If you are setting up virtual hosts, ensure the virtual host definitions are included in the configuration file.
|
||||
Example:
|
||||
Code
|
||||
|
||||
|
||||
// Create a new member for the HTTP server
|
||||
|
||||
````
|
||||
WRKMBRPDM FILE(QUSRSYS/QATMHINSTC)
|
||||
````
|
||||
|
||||
// Select option 3 for APACHEDFT and press F4
|
||||
````
|
||||
COPY FROMBR(APACHEDFT) TOMBR(MYAPACHE)
|
||||
````
|
||||
|
||||
// Edit the new member using DFU
|
||||
|
||||
````
|
||||
WRKMBRPDM FILE(QUSRSYS/QATMHINSTC)
|
||||
````
|
||||
|
||||
// Type 18 next to MYAPACHE and press Enter
|
||||
|
||||
#### Important Notes:
|
||||
|
||||
The TOMBR parameter must be a unique name within the library.
|
||||
The new member's Instance Data must be correctly configured to point to your new Apache instance.
|
||||
Ensure you have the necessary permissions to modify the QUSRSYS/QATMHINSTClibrary.
|
||||
|
||||
You may need to restart the HTTP server after making changes to the configuration.
|
||||
This process duplicates an existing HTTP server instance. If you're configuring a new server, you may need to install Apache first, as well as configure the required directories and configuration files.
|
||||
BIN
Binary file not shown.
+141
@@ -0,0 +1,141 @@
|
||||
# Install and configure Cron Scheduling on IBM i
|
||||
|
||||
From IBM: Now available for #IBMi: cron, via the 'cronie' RPM package! Anacron, too!
|
||||
|
||||
This package can be a good way to schedule your open source jobs or CL commands to run on a regular basis.
|
||||
|
||||
Install cronie yum packages from IBM i ACS Open Source Package Management
|
||||
```
|
||||
Package list:
|
||||
cronie
|
||||
cronie-anacron
|
||||
```
|
||||
# Setting up sample crontab system schedule file
|
||||
|
||||
From your favorite bash editor, edit the system crontab file: in ***/QOpenSys/etc/crontab***
|
||||
|
||||
**The example listed below sends a message to QSYSOPR via calling an IBMi CL Command**
|
||||
|
||||
We also add APPLICATION_ENV=dev where dev is the environment file that is in use to take
|
||||
advantage of the environment switcher code and target the correct systems
|
||||
|
||||
```
|
||||
SHELL=/QOpenSys/pkgs/bin/bash
|
||||
PATH=/QOpenSys/pkgs/bin:/QOpenSys/usr/sbin:/QOpenSys/usr/bin
|
||||
MAILTO=qsecofr
|
||||
APPLICATION_ENV=DEV
|
||||
# For details see man 4 crontabs
|
||||
|
||||
# Example of job definition:
|
||||
# .---------------- minute (0 - 59)
|
||||
# | .------------- hour (0 - 23)
|
||||
# | | .---------- day of month (1 - 31)
|
||||
# | | | .------- month (1 - 12) OR jan,feb,mar,apr ...
|
||||
# | | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
|
||||
# | | | | |
|
||||
# * * * * * ibmi-user-name command to be executed
|
||||
# Run a sample IBMi CL command every minute to send message to QSYSOPR
|
||||
*/1 * * * * QSECOFR system "SNDMSG MSG(CRONJOB) TOUSR(QSYSOPR)"
|
||||
# Run a sample IBMi CL command every minute to send message to QSYSOPR and log any stdout/stderr messages to custom log file /tmp/ibmicommand.log.
|
||||
*/1 * * * * QSECOFR system "SNDMSG MSG(CRONJOB) TOUSR(QSYSOPR)" >> /tmp/ibmicommand.log 2>&1
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
to monitor cron use crond -n -x proc this will dump its opertion to the console
|
||||
|
||||
```
|
||||
|
||||
|
||||
See crontab configuration sample links below for site that can teach you more about setting up your cron jobs via the crontab configration files.
|
||||
https://github.com/richardschoen/howtostuff/blob/master/installcronie.md#links
|
||||
|
||||
***Note: It appears that you must specify the user ID to run the selected job as. In the above example we are running the SNDMSG example as QSECOFR.***
|
||||
```Only /QOpenSys/etc/crontab and the files in /QOpenSys/etc/cron.d/ have a username field though. Ours on IBM i are prefixed with /QOpenSys obviously.```
|
||||
|
||||
Reading link for more info on running as a specific user.
|
||||
https://serverfault.com/questions/352835/crontab-running-as-a-specific-user
|
||||
|
||||
# Make sure crontab file and cron directories are owned by root user for running system jobs
|
||||
|
||||
Run the following commands from a SSH session to make sure root user (QSECOFR) owns the files and directories. Otherwise your crontab will not run correctly because of permission errors even though the crond daemon will start up.
|
||||
|
||||
```
|
||||
cd /QOpenSys/etc
|
||||
chown qsecofr crontab
|
||||
chown qsecofr cron.d
|
||||
chown qsecofr cron.hourly
|
||||
```
|
||||
|
||||
# Starting the crond daemon to run schedule jobs
|
||||
|
||||
Log in to SSH as IBM i QSECOFR level user. ```QSECOFR or a user with *ALLOBJ authority.```
|
||||
|
||||
Type: ```crond``` and press enter.
|
||||
|
||||
|
||||
From a 5250 session, run ***WRKACTJOB SBS(QUSRWRK) JOB(QP0ZSPWP)*** and you should see the active server jobs and threads in the QUSRWRK subsystem.
|
||||
|
||||
```
|
||||
--------------------------------------------------------------------------------
|
||||
QP0ZSPWP QSECOFR BCI .0 PGM-crond SELW
|
||||
--------------------------------------------------------------------------------
|
||||
```
|
||||
Tip: If your crontab jobs are not running, drill in to this job via ```option 5. Work with``` and then display the job log for the active crond daemon ```10. Display job log, if active, on job queue, or pending ```. Usually there will be an error listed if your crontab command line entries are bad or permissions aren't set right on your crond configuration files and directories.
|
||||
|
||||
|
||||
# Ending the crond daemon job
|
||||
|
||||
Log in to SSH as IBM i QSECOFR level user.
|
||||
|
||||
Type: ```kill `cat /QOpenSys/etc/crond.pid` ``` and press enter.
|
||||
|
||||
# Start crond daemon job bash script - startcrond.sh
|
||||
|
||||
This script can be used to start the crond background job as an active job in the QUSRWRK subsystem.
|
||||
|
||||
```
|
||||
#!/QOpenSys/pkgs/bin/bash
|
||||
crond -s
|
||||
```
|
||||
|
||||
# End crond daemon job bash script - endcrond.sh
|
||||
|
||||
This script can be used to end the crond background job if it's active. It used the process id stored in file /QOpenSys/etc/crond.pid to locate and kill the system job.
|
||||
|
||||
```
|
||||
#!/QOpenSys/pkgs/bin/bash
|
||||
kill `cat /QOpenSys/etc/crond.pid`
|
||||
```
|
||||
|
||||
# Start crond daemon job from QSHEXEC CL command
|
||||
|
||||
This CL command example utilizes the QSHEXEC command which is part of the QSHONI library for running QSH/PASE jobs from a regular IBM i job. https://github.com/richardschoen/qshoni
|
||||
|
||||
```
|
||||
QSHONI/QSHEXEC CMDLINE('crond') LOGSTDOUT(*YES)
|
||||
```
|
||||
|
||||
# End crond daemon job from QSHEXEC CL command
|
||||
|
||||
This CL command example utilizes the QSHEXEC command which is part of the QSHONI library for running QSH/PASE jobs from a regular IBM i job. https://github.com/richardschoen/qshoni
|
||||
|
||||
```
|
||||
QSHONI/QSHEXEC CMDLINE('kill ```cat /QOpenSys/etc/crond.pid```') LOGSTDOUT(*YES)
|
||||
```
|
||||
|
||||
This document does not cover production best practices. Best practices should be addressed with your Administration team or by researching Cron security best practices.
|
||||
|
||||
# Links
|
||||
|
||||
Cronie Github Site
|
||||
|
||||
https://github.com/cronie-crond/cronie
|
||||
|
||||
Crontab configuration examples
|
||||
|
||||
https://phoenixnap.com/kb/set-up-cron-job-linux
|
||||
|
||||
https://corenominal.org/2016/05/12/howto-setup-a-crontab-file/
|
||||
|
||||
https://pimylifeup.com/cron-jobs-and-crontab/
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
View Model Implementation
|
||||
This is how the Laravel view model is used with the spares dashboard to remove all the logic
|
||||
from the blade view
|
||||
|
||||
### The DashboardViewModel
|
||||
|
||||
````
|
||||
<?php
|
||||
|
||||
namespace Modules\Spares\ViewModels;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Spares\Repositories\API\APIStorageRepository;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
final class DashboardViewModel
|
||||
{
|
||||
public array $returns = [];
|
||||
public array $returnsLabels = [];
|
||||
public array $returnsValues = [];
|
||||
public float $totalPOValue = 0.0;
|
||||
|
||||
public array $inventory = [];
|
||||
public array $inventoryLabels = [];
|
||||
public array $inventoryValues = [];
|
||||
public float $totalInventoryValue = 0.0;
|
||||
|
||||
public array $issues = [];
|
||||
public array $issuesLabels = [];
|
||||
public array $issuesValues = [];
|
||||
public float $totalIssuesValue = 0.0;
|
||||
|
||||
public array $stock = [];
|
||||
public array $stockLabels = [];
|
||||
public array $stockValues = [];
|
||||
public float $totalStockValue = 0.0;
|
||||
public float $totalStockValueLastMonth = 0.0;
|
||||
|
||||
public float $grandTotal = 0.0;
|
||||
|
||||
public array $topTenIssues = [];
|
||||
public array $topTenInventory = [];
|
||||
|
||||
/**
|
||||
* Logs are normalized for the Blade:
|
||||
* [
|
||||
* ['key'=>'inventory','label'=>'<i ...>Adjustments Sync','run_at'=>'14 Oct 2025, 12:30','server_name'=>'maximo01'],
|
||||
* ...
|
||||
* ]
|
||||
*/
|
||||
public array $logs = [];
|
||||
|
||||
private APIStorageRepository $api;
|
||||
private bool $useCache;
|
||||
private int $cacheMinutes;
|
||||
|
||||
public function __construct(APIStorageRepository $api = null, ?bool $useCache = null, int $cacheMinutes = 60)
|
||||
{
|
||||
$this->api = $api ?? new APIStorageRepository();
|
||||
$this->useCache = $useCache ?? (bool)settings()->group('maximo')->get('dashboard_cache', false);
|
||||
$this->cacheMinutes = $cacheMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the data ready fro the view
|
||||
* @return $this
|
||||
* @throws Throwable
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function build(): self
|
||||
{
|
||||
$start = microtime(true);
|
||||
$apiToken = (string)$this->api->getApiToken();
|
||||
$params = [];
|
||||
|
||||
// ------- Returns -------
|
||||
$this->returns = $this->fetch('/api/v1/returns/index', $apiToken, $params, 'returns');
|
||||
$returnsByWeek = $this->groupByIsoWeek($this->returns, 'SRRTRDT', 'SRRUCST');
|
||||
$this->returnsLabels = array_keys($returnsByWeek);
|
||||
$this->returnsValues = array_values($returnsByWeek);
|
||||
$this->totalPOValue = array_sum($this->returnsValues);
|
||||
|
||||
// ------- Inventory -------
|
||||
$this->inventory = $this->fetch('/api/v1/inventory/index', $apiToken, $params, 'inventory');
|
||||
$this->totalInventoryValue = (float)(collect($this->inventory)->sum('SPACOST') ?: 0.0);
|
||||
$inventoryByWeek = $this->groupByIsoWeek($this->inventory, 'SPATRDT', 'SPACOST');
|
||||
$this->inventoryLabels = array_keys($inventoryByWeek);
|
||||
$this->inventoryValues = array_values($inventoryByWeek);
|
||||
|
||||
// ------- Issues -------
|
||||
$this->issues = $this->fetch('/api/v1/issues/index', $apiToken, $params, 'issues');
|
||||
$issuesByWeek = $this->groupByIsoWeek($this->issues, 'SPRTRDT', 'SPRUCST');
|
||||
$this->issuesLabels = array_keys($issuesByWeek);
|
||||
$this->issuesValues = array_values($issuesByWeek);
|
||||
$this->totalIssuesValue = array_sum($this->issuesValues);
|
||||
|
||||
// ------- Stock -------
|
||||
$stock = $this->fetch('/api/v1/stock/index', $apiToken, $params, 'stock');
|
||||
$stockLastMonth = $this->fetch('/api/v1/stock/index/last_month', $apiToken, $params, 'stock_last_month');
|
||||
|
||||
$totalStockNow = (float)(collect($stock)->sum('SPSRCST') ?: 0.0);
|
||||
$this->totalStockValueLastMonth = (float)(collect($stockLastMonth)->sum('SPSRCST') ?: 0.0);
|
||||
$this->totalStockValue = $totalStockNow - $this->totalStockValueLastMonth;
|
||||
|
||||
$combinedStock = array_merge(is_array($stock) ? $stock : [], is_array($stockLastMonth) ? $stockLastMonth : []);
|
||||
$this->stock = $combinedStock;
|
||||
|
||||
$stockByMonth = $this->groupByMonth($combinedStock, 'SPSTRDT', 'SPSRCST');
|
||||
$this->stockLabels = array_keys($stockByMonth);
|
||||
$this->stockValues = array_values($stockByMonth);
|
||||
array_multisort($this->stockLabels, SORT_ASC, $this->stockValues);
|
||||
|
||||
// ------- Top Tens (defensive) -------
|
||||
$this->topTenIssues = $this->fetch('/api/v1/issues/index/top_ten', $apiToken, $params, 'top_ten_issues');
|
||||
$this->topTenInventory = $this->fetch('/api/v1/inventory/index/top_ten', $apiToken, $params,
|
||||
'top_ten_inventory');
|
||||
|
||||
// ------- Logs (with label mapping & formatted timestamps) -------
|
||||
$labelMap = [
|
||||
'INVENTORY' => '<i class="fa-fw fas fa-sliders-h mr-2"></i>Adjustments Sync',
|
||||
'ISSUES' => '<i class="fas fa-exchange-alt mr-2"></i>Item Usage Sync',
|
||||
'RETURNS' => '<i class="fa-fw fas fa-thermometer-full mr-2"></i>PO Analysis Sync',
|
||||
'STOCK' => '<i class="fa-fw fas fa-warehouse mr-2"></i>Movement Sync',
|
||||
];
|
||||
|
||||
$this->logs = collect(['RETURNS', 'STOCK', 'ISSUES', 'INVENTORY'])
|
||||
->map(function (string $type) use ($labelMap) {
|
||||
$raw = (array)$this->api->getRunLog('/api/v1/runlog/last', $type);
|
||||
|
||||
return [
|
||||
'key' => strtolower($type),
|
||||
'label' => $labelMap[$type] ?? e($type),
|
||||
'run_at' => $this->formatDate($raw['run_at'] ?? null),
|
||||
'server_name' => (string)($raw['server_name'] ?? 'N/A'),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$this->grandTotal = $this->totalPOValue + $this->totalIssuesValue + $this->totalInventoryValue + $this->totalStockValue;
|
||||
|
||||
if (app()->environment(['local', 'dev', 'qat'])) {
|
||||
$runtime = round(microtime(true) - $start, 3);
|
||||
Log::channel('dashboard')->info(emoji('orange') . "Dashboard ViewModel build() runtime: {$runtime} seconds");
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the variables as an array.
|
||||
* @return array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'totalPOValue' => $this->totalPOValue,
|
||||
'totalInventoryValue' => $this->totalInventoryValue,
|
||||
'totalIssuesValue' => $this->totalIssuesValue,
|
||||
'totalStockValueLastMonth' => $this->totalStockValueLastMonth,
|
||||
'totalStockValue' => $this->totalStockValue,
|
||||
'grandTotal' => $this->grandTotal,
|
||||
|
||||
'inventory' => $this->inventory,
|
||||
'inventoryLabels' => $this->inventoryLabels,
|
||||
'inventoryValues' => $this->inventoryValues,
|
||||
|
||||
'stock' => $this->stock,
|
||||
'stockLabels' => $this->stockLabels,
|
||||
'stockValues' => $this->stockValues,
|
||||
|
||||
'issues' => $this->issues,
|
||||
'issuesLabels' => $this->issuesLabels,
|
||||
'issuesValues' => $this->issuesValues,
|
||||
|
||||
'returns' => $this->returns,
|
||||
'returnsLabels' => $this->returnsLabels,
|
||||
'returnsValues' => $this->returnsValues,
|
||||
|
||||
'topTenIssues' => $this->topTenIssues,
|
||||
'topTenInventory' => $this->topTenInventory,
|
||||
'logs' => $this->logs,
|
||||
];
|
||||
}
|
||||
|
||||
// ----------------- helpers -----------------
|
||||
|
||||
/**
|
||||
* A combined log formatter
|
||||
* @param string $url
|
||||
* @param string $token
|
||||
* @param array $params
|
||||
* @param string $name
|
||||
* @return array|mixed
|
||||
*/
|
||||
private function fetch(string $url, string $token, array $params, string $name)
|
||||
{
|
||||
// normalize everything to strings for cache key safety
|
||||
$url = (string)$url;
|
||||
$name = (string)$name;
|
||||
$paramsJson = json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$key = $name . '_' . md5($url . '|' . ($paramsJson ?? '[]'));
|
||||
|
||||
if (!$this->useCache) {
|
||||
return $this->executeApi($token, $url, $params);
|
||||
}
|
||||
|
||||
return Cache::remember($key, now()->addMinutes($this->cacheMinutes), function () use ($token, $url, $params) {
|
||||
return $this->executeApi($token, $url, $params);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A common API executor
|
||||
* @param string $token
|
||||
* @param string $url
|
||||
* @param array $params
|
||||
* @return array
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function executeApi(string $token, string $url, array $params)
|
||||
{
|
||||
try {
|
||||
$data = APIStorageRepository::execute($token, $url, $params);
|
||||
// Always return an array so downstream code is predictable
|
||||
return is_array($data) ? $data : [];
|
||||
} catch (Throwable $e) {
|
||||
if (app()->environment(['local', 'dev', 'qat'])) {
|
||||
Log::channel('dashboard')->warning("API fetch failed for {$url}: {$e->getMessage()}");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** ISO year-week to avoid cross-year collisions, e.g. '2025-W01'. */
|
||||
private function groupByIsoWeek(array $data, string $dateField, string $valueField): array
|
||||
{
|
||||
return collect($data)
|
||||
->filter(fn($i) => !empty($i[$dateField]) && is_numeric($i[$valueField] ?? null))
|
||||
->groupBy(function ($i) use ($dateField) {
|
||||
$d = Carbon::parse($i[$dateField]);
|
||||
return $d->format('o-\WW');
|
||||
})
|
||||
->map(fn($items) => collect($items)->sum($valueField))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private function groupByMonth(array $data, string $dateField, string $valueField): array
|
||||
{
|
||||
return collect($data)
|
||||
->filter(fn($i) => !empty($i[$dateField]) && is_numeric($i[$valueField] ?? null))
|
||||
->groupBy(fn($i) => Carbon::parse($i[$dateField])->format('Y-m'))
|
||||
->map(fn($items) => collect($items)->sum($valueField))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nice helper for format dates
|
||||
* @param string|null $value
|
||||
* @return string
|
||||
*/
|
||||
private function formatDate(?string $value): string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return 'N/A';
|
||||
}
|
||||
try {
|
||||
return Carbon::parse($value)->format('d M Y, H:i');
|
||||
} catch (Throwable $e) {
|
||||
return 'N/A';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
````
|
||||
|
||||
And the new controller DashBoardController.php
|
||||
|
||||
````
|
||||
<?php
|
||||
|
||||
namespace Modules\Spares\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Modules\Spares\ViewModels\DashboardViewModel;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
|
||||
/**
|
||||
* Main View Loader
|
||||
* now uses a view model
|
||||
* @return View
|
||||
*
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$dashboardViewModel = (new DashboardViewModel())->build();
|
||||
|
||||
if (app()->environment(['local', 'dev', 'qat'])) {
|
||||
$useCache = (bool)settings()->group('maximo')->get('dashboard_cache', false);
|
||||
logger()->channel('dashboard')->info(
|
||||
$useCache
|
||||
? emoji('green') . 'Using Cached Data'
|
||||
: emoji('red') . 'Not Using Cached Data'
|
||||
);
|
||||
}
|
||||
return view('spares::Components.dashboard', $dashboardViewModel->toArray());
|
||||
}
|
||||
}
|
||||
|
||||
````
|
||||
|
||||
This abstracts the data from the view and makes it far more maintainable and extendable.
|
||||
@@ -0,0 +1,54 @@
|
||||
### Developer Websites
|
||||
|
||||
In your how directory you will see a new directory epro, this is the base directory
|
||||
with the code base living in /home/<username>/epro/htdocs/epro2,
|
||||
If one has not been created for your please reach out to ``` mark.gregory@mbpa.com```
|
||||
|
||||
All of your work should take place in this environment using the branches as defined above
|
||||
you can switch your own environment with ``` git checkout ``` complete you work and then merge
|
||||
into the develop branch and switch back to complete testing. its wise also to do a ``` git pull origin develop ```
|
||||
to collect other developer changes, if at this stage you get merge conflicts you must resolve these and push back to
|
||||
develop. and merge back into your own issue, feature or hotfix branch.
|
||||
|
||||
This will eliminate all of the code overwrites suffered to date
|
||||
|
||||
### Defined Hosts
|
||||
192.168.0.81:10020 Mark Gregory
|
||||
192.168.0.81:10021 Tomasz
|
||||
192.168.0.81:10022 Malak
|
||||
192.168.0.81:10023 Brad
|
||||
192.168.0.81:10024 Karl
|
||||
|
||||
When we have defined the firewall exclusions you will be able to substitute 192.168.0.81 for 164.39.2.139 we will expand this list as
|
||||
require when new developers are provisioned.
|
||||
|
||||
The current MPBA testing URL is ``` http://164.39.2.139:10008/admin ```
|
||||
and a backup on ``` http://164.39.2.139:10011/admin```
|
||||
|
||||
|
||||
### Production Deployment Process
|
||||
1. Prior to release day a code freeze takes place
|
||||
2. A new branch is created release/version-date
|
||||
3. UAT servers are switched to this branch and retested
|
||||
4. RELEASE DAY, merge release/version-date into master
|
||||
5. Seek release approval via slack.
|
||||
6. Deploy master and then test release and perform any hotfixes as require
|
||||
7. Push any hot fixes back and merge release/version-date into develop
|
||||
8. Release complete - alert team and then code freeze removed.
|
||||
9. Release issues revert the comment and liaise with team.
|
||||
|
||||
The release should take place by the selected developer and on a rota if possible so all
|
||||
developers are upto date with how to release the system.
|
||||
|
||||
|
||||
### Basic System Information
|
||||
PHP Version: 8.0
|
||||
Node Version: 16.20.2
|
||||
Laravel Version: 8.X
|
||||
|
||||
### Git Branch Structure
|
||||
1. MPBA Server /develop
|
||||
2. MediaDev /stage
|
||||
3. MediaUat /qa
|
||||
4. Production /master
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
### System Developer Notes
|
||||
|
||||
This documents how one should develop using the sentinel laravel project.
|
||||
|
||||
#### Git Structure
|
||||
|
||||
The project is tracked using git and has several key branches, we also operate a pull request system and
|
||||
these branches are protected, a pull request must be raised and approved before code can be merged into the
|
||||
relevant develop, stage, release or main branches. ****** Code will not be merged in any other way *****
|
||||
|
||||
#### main
|
||||
|
||||
This is the production branch and is protected from direct commits into this branch except
|
||||
for senior developers, in normal circumstances this branch in never tampered with hot fixes and
|
||||
releases being the only exception. This key branch provides the code for http://mediuk:10008
|
||||
it resides upon the mediuk server in a directory /www/laravel/htdocs.
|
||||
|
||||
#### stage
|
||||
|
||||
This branch is mainly deployed to support user quality testing and reside on http://mediqat:10008
|
||||
NB from time to time other branches may be in effect on this server when progressing towards a software release
|
||||
this structure is normally release/RC-(date)-Vx when (Vx) is is the format of "V" followed by a number to indicate the
|
||||
release candidate version. its location on the mediqat server is /www/laravel/htdocs
|
||||
|
||||
#### develop
|
||||
|
||||
This is the key branch that runs on http://medidev:10008 this is not really to be considered a true development
|
||||
branch as historically this has been use as a pseudo user QAT branch.
|
||||
|
||||
## Important Note
|
||||
|
||||
The medidev server also runs th e Oracle Connectors required to run the Spares (MAXIMO) system and you
|
||||
should ensure that this server is working at all times, this may well be backed up by an additional server
|
||||
running on medidev when time permits.
|
||||
|
||||
There are also other key branches
|
||||
|
||||
#### release
|
||||
|
||||
primarily for release candidates as above.
|
||||
|
||||
#### bugfix
|
||||
|
||||
Branches under this in the format bugfix/details are used to provide fixes for running systems
|
||||
|
||||
#### feature
|
||||
|
||||
This is for new systems, as we use Laravel modules and all code is self contained with within the module
|
||||
follows the format feature/module_name
|
||||
|
||||
#### hotfix
|
||||
|
||||
This branch structure is for hotfixes into the production system ofr emergency updates in the format
|
||||
hotfix/fix_details
|
||||
|
||||
#### support
|
||||
|
||||
This is for minor fixes into all of the Sentinel system.
|
||||
|
||||
#### Commit messages
|
||||
|
||||
An important aspect of commit messages is the ability to search these in good commit messages are vital
|
||||
to prevent developer confusion and code loss when merging.
|
||||
these should follow the format [Module_Name] or [System_Name] and then a concise narrative of what the commit contains.
|
||||
short and non descriptive commit messages will mean that a pull request is rejected with a request to refine the commit
|
||||
message.
|
||||
|
||||
#### Starting a new project
|
||||
|
||||
In amy cases developers have been using the medidev server for pure development and this can cause issues
|
||||
in, medidev should NOT be used for pure development in all cases a separate server should be requested, in the
|
||||
current DB2 database setup there is only one database instance and this can and will cause you issues.
|
||||
|
||||
It's recommended that you develop locally using docker a high performance docker_compose file and a docker directory
|
||||
has been provided, you should install docker, once installed you should reach to to MPBA staff to get a backup of
|
||||
the core databases AAMEDIUK, MPDAT10 and ARCHIVE when using docker you will be using MYSQL as a database server
|
||||
the database backups will be provided and these cna be restored to you local database's
|
||||
|
||||
Always we aware of the fact that the target DB on all Abbot Servers is IBM DB2 and guard code should be produced to
|
||||
ensure that migrations and seeders which are fully support on DB2 , ensure that they only run once as needed
|
||||
and log all of their actions. This aids other developers.
|
||||
|
||||
All permissions and roles should be defined in a seeder so that simple commands can be used to populate the various
|
||||
systems as needed .
|
||||
|
||||
We do use actually Laravel Sail in some instances so you can use
|
||||
|
||||
```
|
||||
docker-compose build --no-cahe
|
||||
or
|
||||
sail build --no-cache
|
||||
|
||||
```
|
||||
|
||||
To download the images and build containers, then once build you can use
|
||||
|
||||
```
|
||||
sail up -d
|
||||
or
|
||||
docker-compose up -d
|
||||
|
||||
```
|
||||
|
||||
This will start the database server, and php webserver using nginx, its recommended that you add
|
||||
entries to your hosts file for the following
|
||||
|
||||
````
|
||||
127.0.0.1 mysql
|
||||
127.0.0.1 mailpit
|
||||
127.0.0.1 redis
|
||||
127.0.0.1 host.docker.internal
|
||||
|
||||
127.0.0.1 medidev.vm
|
||||
127.0.0.1 mediuk.vm
|
||||
127.0.0.1 mediqat.vm
|
||||
127.0.0.1 sonarcube
|
||||
127.0.0.1 nginx
|
||||
|
||||
````
|
||||
|
||||
Your browser will then respond on http://medidev.v, for example. You can use local self certified SSL if you desire and
|
||||
you should reach out to MPBA staff for details.
|
||||
|
||||
### Important Apple Silicon Development
|
||||
|
||||
With later releases of Apple OSX the file system is case sensitive, where the IBM AS400 Apache server / filesystem is
|
||||
not. Therefore its not possible to hold the mysql database files on a case sensitive drive this will cause the
|
||||
server to crash when loading data etc. We use the Linux sub system provided by the container to overcome this
|
||||
and performance issues with docker and shared volumes.
|
||||
|
||||
Beware if you clean your docker system and purge all the volumes you must rebuilt and
|
||||
re-import the database backups.
|
||||
|
||||
One could use a separate case insensitive volume but this will impact docker performance.
|
||||
|
||||
When starting a new project the root branch is develop and you should create your new branch as feature/module name
|
||||
|
||||
### Development Steps
|
||||
|
||||
1. Perform a git pull using the develop branch ```` goit pull origin develop ````
|
||||
2. Create your new feature branch and switch ```` git branch feature/module_name && git checkout feature/module_name````
|
||||
3. Create a suitable env file .env
|
||||
3. Execute docker commands to build system.
|
||||
4. Restore the 3 database backups
|
||||
5. Add the host file entries
|
||||
6. execute ``` docker-compose up -d or sail up -d``` to start the docker system.
|
||||
7. create your new module with ```` php artisan module:make (name)`
|
||||
|
||||
You should now have a running system.
|
||||
|
||||
### Module Naming
|
||||
|
||||
Think about how you name you module and make it as short as possible with NO spaces
|
||||
``` php artisan module:make Spares ``` for example. Changing a module name later is hard and will require a
|
||||
senior developer.
|
||||
|
||||
In previous releases we were using an environment switcher so that .env.dev ate were added to the SCM ( git) this
|
||||
is not now the case and it relies upon the .env file, the .env file SHOULD NOT BE COMMITTED under any circumstances.
|
||||
|
||||
The existing modules provide a great starting point and you should review the code under the Modules directory
|
||||
|
||||
#### FINAL IMPORTANT NOTES
|
||||
|
||||
1. commit and push pull at high frequency
|
||||
|
||||
When a piece has been worked upon commit your changes with a suitable commit message and pull al push any changes, its a
|
||||
good idea to
|
||||
pull and push frequently.
|
||||
|
||||
2. Commit all work at the end of day
|
||||
This is important. ALL WORK should be committed and PUSHED to GIT at the end of the day NO EXCEPTIONS
|
||||
|
||||
3. You are responsible for your own code, if you generate merge conflicts YOU sort them out to ensure no DATA LOSS
|
||||
4. if yo do not commit frequently do not expect your code to be updated to GIT you MUST COMMIT your work
|
||||
|
||||
Do not rely on backups, its your code, own it and ensure its added to the source control system and also ensure you do
|
||||
not over write
|
||||
and core code in the process, if in doubt ASK !!
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
## Docker Usage for Local Development
|
||||
|
||||
This documents hwo to use docker for local development on OSX but can be modified to run under
|
||||
windows, it uses a multi stage build process and the latest alpine images to keep the container sizes to a minimum.
|
||||
it fully supports xdebug and a server version of mysql server. This presents a very fast development
|
||||
experience that does not pollute the host operating system.
|
||||
|
||||
### Why I Use Docker
|
||||
|
||||
Docker plays a crucial role in my deployment strategy. It allows me to containerize my Laravel applications, ensuring
|
||||
consistency across different environments and across multiple services that I need to run a production Laravel app -
|
||||
Queue workers, Schedulers, Nightwatch agent, etc..
|
||||
|
||||
1. Consistent Environments: Every deployment maintains the same configurations, reducing bugs and deployment issues.
|
||||
|
||||
2. Scalability: Docker’s lightweight containers.
|
||||
|
||||
3. The possibility for automated deployment, Integration with Git allows for continuous deployment workflows.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
1. We assume you have docker installed and running
|
||||
2. and also docker-compose installed
|
||||
3. you can also use laravel sail *** the configuration overrides the default sail definitions
|
||||
|
||||
```
|
||||
services:
|
||||
php-fpm:
|
||||
build:
|
||||
context: ./docker/php-fpm
|
||||
dockerfile: Dockerfile
|
||||
container_name: laravel-php
|
||||
working_dir: /var/www/html
|
||||
volumes:
|
||||
# ##########################################################
|
||||
# Source code via bind mount (fast reads on mac:delegated)
|
||||
# This makes docker much faster
|
||||
# ##########################################################
|
||||
- ./:/var/www/html:delegated
|
||||
# ##########################################################
|
||||
# Hot paths on named volumes (fast writes, Linux FS)
|
||||
# This means we have very fast writes as they are not replicated
|
||||
# to the host file system, this means that they must be recreated
|
||||
# when built and also you must run composer install as well
|
||||
# ##########################################################
|
||||
- vendor:/var/www/html/vendor
|
||||
- storage:/var/www/html/storage
|
||||
- cache:/var/www/html/bootstrap/cache
|
||||
environment:
|
||||
PHP_OPCACHE_ENABLE: "1"
|
||||
PHP_OPCACHE_MEMORY: "256"
|
||||
PHP_FPM_MAX_CHILDREN: "16"
|
||||
XDEBUG_MODE: "debug,develop"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
expose:
|
||||
- "9000"
|
||||
- "9003"
|
||||
networks:
|
||||
web:
|
||||
shared:
|
||||
aliases: [ api.medidev.vm, nginx, gateway ]
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: laravel-nginx
|
||||
entrypoint: [ "nginx", "-g", "daemon off;" ]
|
||||
depends_on: [ php-fpm ]
|
||||
volumes:
|
||||
- ./:/var/www/html:ro,delegated
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./docker/nginx/logs:/var/log/nginx
|
||||
- ./docker/nginx/ssl:/etc/nginx/ssl:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
networks:
|
||||
web:
|
||||
shared:
|
||||
aliases:
|
||||
- nginx
|
||||
- gateway
|
||||
- api.medidev.vm
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: laravel-redis
|
||||
command: [ "redis-server", "--save", "", "--appendonly", "no", "--loglevel", "warning" ]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
networks: [ web ]
|
||||
mysql:
|
||||
image: mysql:9.0
|
||||
container_name: laravel-mysql
|
||||
command: [ "mysqld", "--log-error-verbosity=2","--mysqlx=0","--defaults-file=/etc/mysql/conf.d/my.cnf" ]
|
||||
logging:
|
||||
driver: "none"
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: "987400Kmh"
|
||||
MYSQL_DATABASE: "AAMEDIUK"
|
||||
MYSQL_LOG_CONSOLE: "false"
|
||||
#>/dev/null 2>&1
|
||||
entrypoint: [ "sh","-lc","exec mysqld --datadir=/var/lib/mysql --user=mysql --mysqlx=0 --log-error-verbosity=2 " ]
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- dbdata_medidev:/var/lib/mysql/
|
||||
# ##########################################################
|
||||
# Optional custom config / init files:
|
||||
# ##########################################################
|
||||
- ./docker/mysql/conf.d:/etc/mysql/conf.d:ro
|
||||
healthcheck:
|
||||
test: [ "CMD", "mysqladmin", "ping", "-p987400Kmh" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks: [ web ]
|
||||
queue:
|
||||
build:
|
||||
context: ./docker/php-fpm
|
||||
dockerfile: Dockerfile
|
||||
container_name: laravel-queue
|
||||
command: [ "/usr/bin/supervisord", "-c", "/etc/supervisord.conf" ]
|
||||
working_dir: /var/www/html
|
||||
volumes:
|
||||
- ./:/var/www/html:delegated
|
||||
- vendor:/var/www/html/vendor
|
||||
- storage:/var/www/html/storage
|
||||
- cache:/var/www/html/bootstrap/cache
|
||||
depends_on:
|
||||
- redis
|
||||
- mysql
|
||||
networks: [ web ]
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
web:
|
||||
driver: bridge
|
||||
shared:
|
||||
external: true
|
||||
name: shared-frontend
|
||||
volumes:
|
||||
vendor:
|
||||
storage:
|
||||
cache:
|
||||
dbdata_medidev:
|
||||
|
||||
```
|
||||
|
||||
You need to create a docker directory from the project root
|
||||
and add the following files to this directory once downloaded.
|
||||
|
||||
[Download docker.zip](assets/docker.zip){: download}
|
||||
|
||||
you will also need to contact the author for a copy of the mysql databases;
|
||||
|
||||
Once all the files are in place you can execute
|
||||
|
||||
```
|
||||
sail up --build
|
||||
or
|
||||
docker-compose build --no-cache
|
||||
```
|
||||
|
||||
Fix invalid cache path after rebuild as were using names paths
|
||||
|
||||
You can also use this command
|
||||
|
||||
````
|
||||
docker exec -it laravel-php bash -lc \
|
||||
"mkdir -p bootstrap/cache storage/framework/{cache,sessions,views} \
|
||||
&& chown -R www-data:www-data bootstrap storage \
|
||||
&& chmod -R ug+rwX bootstrap storage"
|
||||
`````
|
||||
|
||||
NB also run composer install
|
||||
|
||||
````
|
||||
docker network create shared-frontend
|
||||
````
|
||||
|
||||
This makes the necessary cache dirs etc
|
||||
|
||||
````
|
||||
mkdir -p bootstrap/cache storage/framework/{cache,sessions,views,testing,cache/data} && chown -R www-data:www-data bootstrap storage
|
||||
````
|
||||
@@ -0,0 +1,96 @@
|
||||
# Laravel Environment Switcher
|
||||
|
||||
### Introduction
|
||||
|
||||
*** THIS HAS BEEN DEPRECATED AND IS NO LONGER USED
|
||||
|
||||
The basic operation of the environment switcher is to permit the use of different environment files
|
||||
based upon a variable that is added to the web servers configuration to control which one is loaded.
|
||||
|
||||
when you make changes to the environment files you need to execute
|
||||
|
||||
``php artisan config:clear``
|
||||
|
||||
and
|
||||
``php artisan config:cache``
|
||||
|
||||
The various env files are committed to source control with the exception of the .local environment file
|
||||
this file is used for local development and is developer specific
|
||||
|
||||
1. .dev is used on MEDIDEV
|
||||
2. .qat is used on MEDIQAT
|
||||
3. .prod is used on MEDIUK
|
||||
|
||||
.local is reserved for local development environments
|
||||
|
||||
The files in use are
|
||||
``` /app/Bootstrap/DetectEnviroment.php``` and ``` /app/Bootstrap/LoadEnvironment.php```
|
||||
These are added to ``` app/Http/Kernel.php```
|
||||
|
||||
```
|
||||
protected $bootstrappers = [
|
||||
\App\Bootstrap\DetectEnvironment::class,
|
||||
\App\Bootstrap\LoadEnvironment::class,
|
||||
\Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class,
|
||||
\Illuminate\Foundation\Bootstrap\LoadConfiguration::class,
|
||||
\Illuminate\Foundation\Bootstrap\HandleExceptions::class,
|
||||
\Illuminate\Foundation\Bootstrap\RegisterFacades::class,
|
||||
\Illuminate\Foundation\Bootstrap\SetRequestForConsole::class,
|
||||
\Illuminate\Foundation\Bootstrap\RegisterProviders::class,
|
||||
\Illuminate\Foundation\Bootstrap\BootProviders::class,
|
||||
];
|
||||
|
||||
```
|
||||
|
||||
and for console commands These are added to ``` app/Helpers/Kernel.php```
|
||||
|
||||
````
|
||||
protected $bootstrappers = [
|
||||
\App\Bootstrap\DetectEnvironment::class,
|
||||
\App\Bootstrap\LoadEnvironment::class,
|
||||
//\Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class,
|
||||
\Illuminate\Foundation\Bootstrap\LoadConfiguration::class,
|
||||
\Illuminate\Foundation\Bootstrap\HandleExceptions::class,
|
||||
\Illuminate\Foundation\Bootstrap\RegisterFacades::class,
|
||||
\Illuminate\Foundation\Bootstrap\SetRequestForConsole::class,
|
||||
\Illuminate\Foundation\Bootstrap\RegisterProviders::class,
|
||||
\Illuminate\Foundation\Bootstrap\BootProviders::class,
|
||||
];
|
||||
````
|
||||
|
||||
This permits the console to use the relevant env file also
|
||||
|
||||
### Apache Web Server Configuration
|
||||
|
||||
For the Apache Web Server a line is added to the configuration file in
|
||||
`` /www/laravel/conf/httpd.conf``
|
||||
This line is `` SetEnv APPLICATION_ENV dev`` for example on MEDIDEV and the corresponding env file
|
||||
created .env.dev
|
||||
|
||||
Apache then needs to be restarted for the new environment file to be activated.
|
||||
|
||||
### nginx configuration
|
||||
|
||||
The config file for valet etc is changed to add these variables
|
||||
|
||||
````
|
||||
location ~ [^/]\.php(/|$) {
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass "unix:/Users/markgregory/.config/valet/valet.sock";
|
||||
fastcgi_index "/Users/markgregory/.composer/vendor/laravel/valet/server.php";
|
||||
include fastcgi_params;
|
||||
fastcgi_param APPLICATION_ENV "local";
|
||||
fastcgi_param APP_ENV "local";
|
||||
fastcgi_param SCRIPT_FILENAME "/Users/markgregory/.composer/vendor/laravel/valet/server.php";
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
}
|
||||
````
|
||||
|
||||
Again nginx should be restarted with nginx -s reload or for valet ``` valet restart ```
|
||||
|
||||
### Debugging
|
||||
|
||||
When setting this up if the variables are not added you will receive a warning and the app will not
|
||||
start, for debug only you can add normal entries to .env but do not commit these to the SVM .env is only committed
|
||||
to show it's not in use.
|
||||
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
### GitHub Branching Name Best Practices
|
||||
|
||||
On GitHub, branching is a crucial component of collaborative software development. In addition to keeping your project
|
||||
organized, using consistent and unambiguous branch naming conventions makes it easier for team members to quickly
|
||||
comprehend each branch's purpose. Some best practices for naming branches in your GitHub projects are listed below.
|
||||
|
||||
1. Use Prefixes to Indicate Purpose
|
||||
Prefixing branch names based on their purpose helps categorize and identify them quickly. Here are some common
|
||||
prefixes:
|
||||
|
||||
feature/: For new features or functionalities.
|
||||
|
||||
bugfix/: For fixing bugs in the code.
|
||||
|
||||
hotfix/: For urgent patches, usually applied to production.
|
||||
|
||||
design/: For user interface or user experience updates.
|
||||
|
||||
refactor/: For improving code structure without changing functionality.
|
||||
|
||||
test/: For writing or improving automated tests.
|
||||
|
||||
doc/: For documentation updates.
|
||||
|
||||
### Example Prefix Usage
|
||||
|
||||
feature/user-authentication
|
||||
|
||||
bugfix/fix-login-error
|
||||
|
||||
hotfix/urgent-patch-crash
|
||||
|
||||
design/update-navbar
|
||||
|
||||
refactor/remove-unused-code
|
||||
|
||||
test/add-unit-tests
|
||||
|
||||
doc/update-readme
|
||||
|
||||
2. Keep Names Short and Descriptive
|
||||
Branch names should be concise yet informative. A good branch name briefly describes what it is for without being
|
||||
overly long or vague. Here are some tips:
|
||||
|
||||
Use hyphens (-) to separate words for better readability.
|
||||
|
||||
Avoid generic terms like update, changes, or stuff.
|
||||
|
||||
Focus on the primary task or issue the branch addresses.
|
||||
|
||||
### Examples of Clear Branch Names
|
||||
|
||||
'''
|
||||
Feature Branches
|
||||
feature/add-user-profile
|
||||
feature/implement-chat-notifications
|
||||
Bug Fixes
|
||||
bugfix/correct-date-display
|
||||
bugfix/fix-404-error
|
||||
Design Updates
|
||||
design/improve-dashboard-ui
|
||||
design/revise-mobile-layout
|
||||
Refactoring
|
||||
refactor/optimize-database-queries
|
||||
refactor/simplify-api-routes
|
||||
Hotfixes
|
||||
hotfix/security-patch
|
||||
hotfix/fix-login-issue
|
||||
Documentation
|
||||
doc/add-api-instructions
|
||||
doc/update-contributor-guidelines
|
||||
'''
|
||||
|
||||
3. Incorporate Ticket Numbers or Identifiers
|
||||
For teams using project management tools like Jira or Trello, include the ticket number or issue ID in the branch
|
||||
name. This links the branch to its corresponding task, making it easier to track progress.
|
||||
|
||||
## Examples with Ticket Numbers
|
||||
|
||||
feature/JIRA-1234-add-login
|
||||
|
||||
bugfix/TICKET-567-resolve-crash
|
||||
|
||||
hotfix/ISSUE-890-fix-api
|
||||
|
||||
4. Collaborate on a Branch Naming Strategy
|
||||
Every team is different, so it’s crucial to agree on a branching strategy that works for everyone. Document the
|
||||
naming conventions in your project’s contribution guidelines or README file so that new contributors can easily
|
||||
follow them.
|
||||
|
||||
### Benefits of Consistent Branch Naming
|
||||
|
||||
Improved Collaboration: Team members can quickly understand the purpose of a branch without needing extra context.
|
||||
|
||||
Easier Navigation: Searching for specific branches becomes straightforward.
|
||||
|
||||
Better Automation: Many CI/CD tools can leverage structured branch names for workflows (e.g., deploying hotfix/ branches
|
||||
automatically to production).
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# MPBA Helpdesk
|
||||
|
||||
The MPBA Helpdesk is available at [Helpdesk](https://helpdesk.mpba.dev).
|
||||
|
||||
You can create tickets here once you have a valid account and use the ticket numbers in your branches as required.
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
# Welcome to Sentinel Docs
|
||||
|
||||
## Sentinel EPRO
|
||||
|
||||
This document details the Sentinel development process that will prevent collisions and code overwrites
|
||||
it's a living document and will be updated frequently and as the process is refined.
|
||||
|
||||
The MPBA Helpdesk is available at [Helpdesk](https://helpdesk.mpba.dev).
|
||||
|
||||
This documentation used mark down files, if you wish to read more please checkout
|
||||
out [Mark Down Guild](https://www.markdownguide.org/).
|
||||
|
||||
One important aspect of this is that in an ideal world one would develop on a local system with a suitable
|
||||
ODBC driver, if this is not available then we have made a development environment for each developer
|
||||
This system resides on the MPBA AS400 server, the details of this developer system is detailed below.
|
||||
|
||||
This may be in some cases informing developers of how 'git' does its work its not a definitive
|
||||
guide to git and anyone who is unsure should reach out so that any issues can be quickly dealt with.
|
||||
|
||||
On important aspect of this system is to use a proper git branch structure no longer should developers be working on
|
||||
the master branch, the master branch is reserved for production servers only.
|
||||
Developers should always create a branch off develop which will contain the latest developer codebase.
|
||||
|
||||
Git is a DVCS we are all used to the older file based SCM's like subversion and PVCS a DVCS only records the
|
||||
changes. its important to understand that in every case its better to go git pull and push
|
||||
frequently.
|
||||
|
||||
Also do a ``` git fetch ``` this will download new branches for you
|
||||
|
||||
Please do ``` git pull ``` and ``` git push ``` as frequently as possible.
|
||||
|
||||
Also another useful command is ``` git status``` the output form this speaks for itself as does ``` git log ```.
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Please make you commit messages useful for developers reason being we can search for a commit message
|
||||
and isolate changed code quickly
|
||||
so "Module Debug" is not helpful but "Module debug - corrected issie with service provider and updated .gitignore" is
|
||||
far more useful
|
||||
|
||||
### Production Branches
|
||||
|
||||
Production servers will only ever use master or main these are considered to be protected
|
||||
branches there will not be used to make live edits under any circumstances.
|
||||
the only exception is a Priority One emergency with a system down and in operable,
|
||||
even in this case the developer must ensure that develop is also upto date.
|
||||
|
||||
In the case of such an emergency the assigned developer should create a new branch hotfix/issue-name and
|
||||
perform the fix task in there and merge back into master and then master though to develop.
|
||||
|
||||
```` git branch hotfix\error-500-sparos ````
|
||||
|
||||
then
|
||||
|
||||
```` git checkout hotfix\error-500-sparos ````
|
||||
|
||||
perform your fixes and verify they are ok push all changes to gitlab and
|
||||
|
||||
```` git checkout master ````
|
||||
|
||||
then
|
||||
|
||||
```` git merge hotfix\error-500-sparos ````
|
||||
then
|
||||
|
||||
```` git push origin master ````
|
||||
|
||||
then verify fix worked ok finally
|
||||
|
||||
```` git checkout develop ````
|
||||
then
|
||||
|
||||
```` git merge master &&& git push origin develop ````
|
||||
|
||||
All of your fixes are in place and merged back.
|
||||
|
||||
### Branch Model
|
||||
|
||||
The following branches are permitted
|
||||
|
||||
* 'master' or 'main' for production servers only
|
||||
* 'develop' main branch for development environments
|
||||
* 'qat' for UAT servers
|
||||
* 'stage' pre production servers *** if used
|
||||
* 'issues' for code fixes
|
||||
* 'feature' for major improvements and new systems
|
||||
* 'hotfix' for emergency fixes
|
||||
* 'archive' this is for copies of various branches for convenience
|
||||
* 'release' for candidate releases that are production ready and may be used on a pre-prod server
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
### Indexing DB2 for speed
|
||||
|
||||
This area details why you should add indexes to key fields to increase DB2 lookup performance
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
### Developer Knowledge Base
|
||||
|
||||
### Laravel Eloquent
|
||||
|
||||
to debug an SQL Query you can use
|
||||
|
||||
```
|
||||
|
||||
$rawSql = vsprintf(str_replace(['?'], ['\'%s\''], $query->toSql()), $query->getBindings());
|
||||
|
||||
```
|
||||
|
||||
This will hold the rea; SQL call in $rawSQL
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
### Sentinel Migrations
|
||||
|
||||
This contains important information regarding migrations.
|
||||
|
||||
### Migration Errors
|
||||
|
||||
NB these migrations should only been run on MEDIDEV
|
||||
|
||||
This is an important step, the current DB_USER on mediuk is not as powerful as
|
||||
a user with QSECOFR, migrations will fail with a resource 904 error, to overcome this
|
||||
change the active env file DB_USERNAME and DB_PASSWORD and re-run the migration.
|
||||
|
||||
When running migrations on production always for this with care
|
||||
|
||||
To run a migration
|
||||
|
||||
```
|
||||
php artisan module:migrate Settings
|
||||
|
||||
```
|
||||
|
||||
to revert the migration
|
||||
|
||||
```
|
||||
php artisan module:migrate-rollback Settings
|
||||
|
||||
```
|
||||
|
||||
### Writing Migrations
|
||||
|
||||
In line with general Laravel Migration creation and Taylor Ortwel we do not create a down action to prevent tables
|
||||
being erased in error. Also we should check if a table exists and example ois below.
|
||||
|
||||
```
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable(Priority::TABLE)) {
|
||||
Schema::create(Priority::TABLE, function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->integer('is_default')->default(0);
|
||||
$table->integer('is_protected')->default(0);
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->string('title')->nullable();
|
||||
$table->string('slug')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->char('uuid', 36)->nullable();
|
||||
$table->timestamp('deleted_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
One should never run migrations on production, any tables are to be created using IBM Utilities.
|
||||
*** and Never run the commands php artisan migrate, php artisan migrate-rollback or php artisan db:wipe.
|
||||
|
||||
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: "New page"
|
||||
language: "en"
|
||||
previous: "index.html"
|
||||
---
|
||||
|
||||
## Sentinel EPRO
|
||||
|
||||
### System Information
|
||||
PHP Version: 8.0
|
||||
Node Version: 16.20.2
|
||||
Laravel Version: 8.X
|
||||
|
||||
# Main Development AS400
|
||||
Development URL: http://164.39.2.139:10011/
|
||||
Server Path: /www/ADC8/htdocs/epro2/
|
||||
branch: develop
|
||||
|
||||
# Custom Development AS400 ( Malak's Work )
|
||||
Development URL: http://164.39.2.139:10008/login
|
||||
Server Path: /www/epro/htdocs/epro2
|
||||
branch: master
|
||||
|
||||
### Git Branch Structure
|
||||
1. MPBA Server /develop
|
||||
2. MediaDev /stage
|
||||
3. MediaUat /qa
|
||||
4. Production /master
|
||||
|
||||
Other possible branch names are:
|
||||
production
|
||||
main
|
||||
staging
|
||||
*** it is to developers to adopt these as needed.
|
||||
|
||||
|
||||
The Application does not currently use redis nor memcached and has no reliance upon these caching
|
||||
servers.
|
||||
|
||||
### Important files
|
||||
There are few important files that change from host to host, these are contained in the provision directory
|
||||
and should be kept upto date with their respective target hosts
|
||||
the Keepass file will contain urls and credentials for the various hosts systems.
|
||||
|
||||
|
||||
### Local Development
|
||||
|
||||
There are several alternatives when it comes to local development of web application
|
||||
development, One can use vagrant, Laravel Valet or MAMP LAMP etc,one of the best development systems is
|
||||
docker, this builds containers for the various components and is available
|
||||
on all major platforms.
|
||||
|
||||
The first thing to do on your development machine is to install docker and docker desktop along with support for
|
||||
docker composer.
|
||||
|
||||
#### Image Storage
|
||||
When running docker the php artisan storage:link does not resolve to the correct path
|
||||
to overcome this issue do a docker ps and find the container ID
|
||||
```
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
1dd8f887cebb sail-8.1/app "start-container" 19 hours ago Up 15 minutes 0.0.0.0:80->80/tcp, :::80->80/tcp, 0.0.0.0:5173->5173/tcp, :::5173->5173/tcp, 8000/tcp epro2-sentinal-1
|
||||
4d60252a7927 mailhog/mailhog "MailHog" 3 days ago Up 15 minutes 0.0.0.0:1025->1025/tcp, :::1025->1025/tcp, 0.0.0.0:8025->8025/tcp, :::8025->8025/tcp mailhog
|
||||
```
|
||||
these execute
|
||||
```
|
||||
docker exec -it 1dd8f887cebb /bin/sh
|
||||
```
|
||||
navigate to public and remove the storage link, then execute
|
||||
``` ln -s ../storage/app/public storage ```
|
||||
|
||||
this will link the storage folder correctly
|
||||
and do not run storage link as this wil use the UPLOAD_DIR flag from the .env file that will contain the an incorrect
|
||||
path
|
||||
|
||||
On AS400 hosts this is normally /tmp/quotations and storage link does work properly
|
||||
|
||||
#### SSL Self Signed Certificates
|
||||
to import your local self signed certs into chrome got to settings then setting privacy and security then
|
||||
Authorities click import and navigate to the .valet directory then CA then import .valet/CA/LaravelValetCASelfSigned.pem
|
||||
accepting all the options and then restart chrome, you local site is now using SSL.
|
||||
|
||||
|
||||
#### Introduction
|
||||
|
||||
Laravel Sail is a light-weight command-line interface for interacting with Laravel's default Docker
|
||||
development environment. Sail provides a great starting point for building a Laravel application using PHP, MySQL, and Redis without requiring prior Docker experience.
|
||||
You can also use laravel valet ( see below)
|
||||
|
||||
At its heart, Sail is the docker-compose.yml file and the sail script that is stored at the root of your project. The sail script provides a CLI with convenient methods for interacting with the Docker containers defined by the docker-compose.yml file.
|
||||
|
||||
Laravel Sail is supported on macOS, Linux, and Windows (via WSL2).
|
||||
|
||||
#### Initial Installation
|
||||
Laravel Sail is automatically installed with all new Laravel applications so you may start using it
|
||||
immediately. To learn how to create a new Laravel application, please consult Laravel's installation
|
||||
documentation for your operating system.
|
||||
During installation, you will be asked to choose which Sail supported services your application will be
|
||||
interacting with.
|
||||
|
||||
To install Laravel sail use ``` composer require --dev laravel\sail```
|
||||
|
||||
|
||||
The documentation is available at https://laravel.com/docs/10.x/sail
|
||||
|
||||
#### Docker Notes
|
||||
|
||||
Some important docker commands are as follows:
|
||||
|
||||
NB when running these commands ensure that you have no running instances for example executing sail down.
|
||||
|
||||
``` docker system prune -a```
|
||||
This command will remove the build containers and any downloaded containers, this is useful for saving disk space
|
||||
|
||||
``` docker volume rm $(docker volume ls -q) ```
|
||||
Used to delete any of the volumes created, this is where the database is stored etc again great for cleaning up
|
||||
and saving disk space.
|
||||
|
||||
``` sail build --no-cache ```
|
||||
this is perfect for rebuilding your containers
|
||||
|
||||
### Linux ODBC Installs
|
||||
The ODBC drivers for linux can present a challenge and its important to
|
||||
install IBM Client Access for Linux, this includes the CLI drivers for many linux distributions
|
||||
The database.php in the config directory is the place to set the
|
||||
drivers and the DSN one also add a specific config file if needed.
|
||||
|
||||
We have set credentials in the env file to suite, typically to aid switching between databases
|
||||
we recommend using the same db credentials as 192.168.0.81 ( MPBA AS400 Host)
|
||||
|
||||
|
||||
'odbc_local' => [
|
||||
'driver' => 'odbc',
|
||||
'odbc_driver' => '/opt/ibm/iaccess/lib64/libcwbodbc.so',
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'database' => env('DB_DATABASE'),
|
||||
'username' => env('DB_USERNAME'),
|
||||
'password' => env('DB_PASSWORD'),
|
||||
'dsn' => 'Driver={IBM i Access ODBC Driver 64-bit};System=192.168.0.81;AlwaysCalculateResultLength=1;CCSID=1208;NAM=1;DBQ=,'.env('DBQ'),
|
||||
'options' => [
|
||||
PDO::ATTR_CASE => PDO::CASE_NATURAL, // PDO::CASE_UPPER PDO::CASE_LOWER PDO::CASE_NATURAL
|
||||
//PDO::ATTR_AUTOCOMMIT => false
|
||||
],WARN - #c.j.p.b.d.BladeDebugPathMapper
|
||||
|
||||
DB2 on docker is presenting more of a challenge as none of the current
|
||||
ODBC drivers see to work as expected.
|
||||
|
||||
#### Laravel Valet Usage Guidelines
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# AS400 Queue Operation
|
||||
|
||||
### Overview
|
||||
|
||||
There is a core issue with Laravel and IBM Db2 drivers in the current version of sentinel,.
|
||||
this means that queues will not run properly that have any large payloads teh table struct is not
|
||||
suitable and jbs will simply fail to load into the table called QUEUE.
|
||||
|
||||
### The solution
|
||||
|
||||
The easiest way to fix this issue is not to use DB2 for queue operation but switch to REDIS
|
||||
the REDIS sever has been installed on MEDIDEV and can now be used to Caching, sessions and also more importantly
|
||||
QUEUES.
|
||||
|
||||
The entries in the env file set these to use redis as opposed to database, file system or array.
|
||||
|
||||
One further issue when using Maximo with cron is that cron has no concept of the Oracle Environment Variables
|
||||
we are devising a solution for this issue.
|
||||
|
||||
#### current cron entries for MEDIDEV
|
||||
|
||||
```
|
||||
SHELL=/QOpenSys/pkgs/bin/bash
|
||||
PATH=/QOpenSys/pkgs/bin:/QOpenSys/usr/sbin:/QOpenSys/usr/bin
|
||||
MAILTO=qsecofr
|
||||
APPLICATION_ENV=dev
|
||||
|
||||
# For details see man 4 crontabs
|
||||
|
||||
# Example of job definition:
|
||||
|
||||
# .---------------- minute (0 - 59)
|
||||
|
||||
# | .------------- hour (0 - 23)
|
||||
|
||||
# | | .---------- day of month (1 - 31)
|
||||
|
||||
# | | | .------- month (1 - 12) OR jan,feb,mar,apr ...
|
||||
|
||||
# | | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
|
||||
|
||||
# | | | | |
|
||||
|
||||
# * * * * * user-name command to be executed
|
||||
|
||||
* * * * * pricedx1 php /www/laravel/htdocs/artisan schedule:run
|
||||
*/1 * * * * pricedx1 php /www/laravel/htdocs/artisan queue:work --max-time=30
|
||||
```
|
||||
|
||||
This means that the cron job runs every minute and then for a maximum of 30 seconds, if there are no jobs to process
|
||||
then
|
||||
the queue:work command will quite.
|
||||
|
||||
in most cases a maximum of 3 crn jobs will run on the AS400 1, for the schedule and then two for the queue:work command
|
||||
if the --max-time parameter is removed then the queue:work command does not quit and you end up with lots of cron jobs
|
||||
running
|
||||
which is not ideal as this will kill system resources and slow the AS400 server.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
### Redis Server
|
||||
|
||||
to start the redis server on AS400 execute
|
||||
|
||||
```
|
||||
redis-server --daemonize yes
|
||||
```
|
||||
|
||||
you can then use
|
||||
|
||||
redis-cli ping
|
||||
|
||||
to see if it;s working
|
||||
|
||||
+665
@@ -0,0 +1,665 @@
|
||||
# Task Scheduling
|
||||
|
||||
- [Introduction](#introduction)
|
||||
- [Defining Schedules](#defining-schedules)
|
||||
- [Scheduling Artisan Commands](#scheduling-artisan-commands)
|
||||
- [Scheduling Queued Jobs](#scheduling-queued-jobs)
|
||||
- [Scheduling Shell Commands](#scheduling-shell-commands)
|
||||
- [Schedule Frequency Options](#schedule-frequency-options)
|
||||
- [Timezones](#timezones)
|
||||
- [Preventing Task Overlaps](#preventing-task-overlaps)
|
||||
- [Running Tasks on One Server](#running-tasks-on-one-server)
|
||||
- [Background Tasks](#background-tasks)
|
||||
- [Maintenance Mode](#maintenance-mode)
|
||||
- [Schedule Groups](#schedule-groups)
|
||||
- [Running the Scheduler](#running-the-scheduler)
|
||||
- [Sub-Minute Scheduled Tasks](#sub-minute-scheduled-tasks)
|
||||
- [Running the Scheduler Locally](#running-the-scheduler-locally)
|
||||
- [Task Output](#task-output)
|
||||
- [Task Hooks](#task-hooks)
|
||||
- [Events](#events)
|
||||
|
||||
<a name="introduction"></a>
|
||||
|
||||
## Introduction
|
||||
|
||||
In the past, you may have written a cron configuration entry for each task you needed to schedule on your server.
|
||||
However, this can quickly become a pain because your task schedule is no longer in source control and you must SSH into
|
||||
your server to view your existing cron entries or add additional entries.
|
||||
|
||||
Laravel's command scheduler offers a fresh approach to managing scheduled tasks on your server. The scheduler allows you
|
||||
to fluently and expressively define your command schedule within your Laravel application itself. When using the
|
||||
scheduler, only a single cron entry is needed on your server. Your task schedule is typically defined in your
|
||||
application's `routes/console.php` file.
|
||||
|
||||
<a name="defining-schedules"></a>
|
||||
|
||||
## Defining Schedules
|
||||
|
||||
You may define all of your scheduled tasks in your application's `routes/console.php` file. To get started, let's take a
|
||||
look at an example. In this example, we will schedule a closure to be called every day at midnight. Within the closure
|
||||
we will execute a database query to clear a table:
|
||||
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::call(function () {
|
||||
DB::table('recent_users')->delete();
|
||||
})->daily();
|
||||
|
||||
In addition to scheduling using closures, you may also
|
||||
schedule [invokable objects](https://secure.php.net/manual/en/language.oop5.magic.php#object.invoke). Invokable objects
|
||||
are simple PHP classes that contain an `__invoke` method:
|
||||
|
||||
Schedule::call(new DeleteRecentUsers)->daily();
|
||||
|
||||
If you prefer to reserve your `routes/console.php` file for command definitions only, you may use the `withSchedule`
|
||||
method in your application's `bootstrap/app.php` file to define your scheduled tasks. This method accepts a closure that
|
||||
receives an instance of the scheduler:
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
->withSchedule(function (Schedule $schedule) {
|
||||
$schedule->call(new DeleteRecentUsers)->daily();
|
||||
})
|
||||
|
||||
If you would like to view an overview of your scheduled tasks and the next time they are scheduled to run, you may use
|
||||
the `schedule:list` Artisan command:
|
||||
|
||||
```bash
|
||||
php artisan schedule:list
|
||||
```
|
||||
|
||||
<a name="scheduling-artisan-commands"></a>
|
||||
|
||||
### Scheduling Artisan Commands
|
||||
|
||||
In addition to scheduling closures, you may also schedule [Artisan commands](/docs/{{version}}/artisan) and system
|
||||
commands. For example, you may use the `command` method to schedule an Artisan command using either the command's name
|
||||
or class.
|
||||
|
||||
When scheduling Artisan commands using the command's class name, you may pass an array of additional command-line
|
||||
arguments that should be provided to the command when it is invoked:
|
||||
|
||||
use App\Console\Commands\SendEmailsCommand;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('emails:send Taylor --force')->daily();
|
||||
|
||||
Schedule::command(SendEmailsCommand::class, ['Taylor', '--force'])->daily();
|
||||
|
||||
<a name="scheduling-artisan-closure-commands"></a>
|
||||
|
||||
#### Scheduling Artisan Closure Commands
|
||||
|
||||
If you want to schedule an Artisan command defined by a closure, you may chain the scheduling related methods after the
|
||||
command's definition:
|
||||
|
||||
Artisan::command('delete:recent-users', function () {
|
||||
DB::table('recent_users')->delete();
|
||||
})->purpose('Delete recent users')->daily();
|
||||
|
||||
If you need to pass arguments to the closure command, you may provide them to the `schedule` method:
|
||||
|
||||
Artisan::command('emails:send {user} {--force}', function ($user) {
|
||||
// ...
|
||||
})->purpose('Send emails to the specified user')->schedule(['Taylor', '--force'])->daily();
|
||||
|
||||
<a name="scheduling-queued-jobs"></a>
|
||||
|
||||
### Scheduling Queued Jobs
|
||||
|
||||
The `job` method may be used to schedule a [queued job](/docs/{{version}}/queues). This method provides a convenient way
|
||||
to schedule queued jobs without using the `call` method to define closures to queue the job:
|
||||
|
||||
use App\Jobs\Heartbeat;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::job(new Heartbeat)->everyFiveMinutes();
|
||||
|
||||
Optional second and third arguments may be provided to the `job` method which specifies the queue name and queue
|
||||
connection that should be used to queue the job:
|
||||
|
||||
use App\Jobs\Heartbeat;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
// Dispatch the job to the "heartbeats" queue on the "sqs" connection...
|
||||
Schedule::job(new Heartbeat, 'heartbeats', 'sqs')->everyFiveMinutes();
|
||||
|
||||
<a name="scheduling-shell-commands"></a>
|
||||
|
||||
### Scheduling Shell Commands
|
||||
|
||||
The `exec` method may be used to issue a command to the operating system:
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::exec('node /home/forge/script.js')->daily();
|
||||
|
||||
<a name="schedule-frequency-options"></a>
|
||||
|
||||
### Schedule Frequency Options
|
||||
|
||||
We've already seen a few examples of how you may configure a task to run at specified intervals. However, there are many
|
||||
more task schedule frequencies that you may assign to a task:
|
||||
|
||||
```
|
||||
|------------------------------------|----------------------------------------------------------|
|
||||
| Method | Description |
|
||||
|------------------------------------|----------------------------------------------------------|
|
||||
| `->cron('* * * * *');` | Run the task on a custom cron schedule. |
|
||||
| `->everySecond();` | Run the task every second. |
|
||||
| `->everyTwoSeconds();` | Run the task every two seconds. |
|
||||
| `->everyFiveSeconds();` | Run the task every five seconds. |
|
||||
| `->everyTenSeconds();` | Run the task every ten seconds. |
|
||||
| `->everyFifteenSeconds();` | Run the task every fifteen seconds. |
|
||||
| `->everyTwentySeconds();` | Run the task every twenty seconds. |
|
||||
| `->everyThirtySeconds();` | Run the task every thirty seconds. |
|
||||
| `->everyMinute();` | Run the task every minute. |
|
||||
| `->everyTwoMinutes();` | Run the task every two minutes. |
|
||||
| `->everyThreeMinutes();` | Run the task every three minutes. |
|
||||
| `->everyFourMinutes();` | Run the task every four minutes. |
|
||||
| `->everyFiveMinutes();` | Run the task every five minutes. |
|
||||
| `->everyTenMinutes();` | Run the task every ten minutes. |
|
||||
| `->everyFifteenMinutes();` | Run the task every fifteen minutes. |
|
||||
| `->everyThirtyMinutes();` | Run the task every thirty minutes. |
|
||||
| `->hourly();` | Run the task every hour. |
|
||||
| `->hourlyAt(17);` | Run the task every hour at 17 minutes past the hour. |
|
||||
| `->everyOddHour($minutes = 0);` | Run the task every odd hour. |
|
||||
| `->everyTwoHours($minutes = 0);` | Run the task every two hours. |
|
||||
| `->everyThreeHours($minutes = 0);` | Run the task every three hours. |
|
||||
| `->everyFourHours($minutes = 0);` | Run the task every four hours. |
|
||||
| `->everySixHours($minutes = 0);` | Run the task every six hours. |
|
||||
| `->daily();` | Run the task every day at midnight. |
|
||||
| `->dailyAt('13:00');` | Run the task every day at 13:00. |
|
||||
| `->twiceDaily(1, 13);` | Run the task daily at 1:00 & 13:00. |
|
||||
| `->twiceDailyAt(1, 13, 15);` | Run the task daily at 1:15 & 13:15. |
|
||||
| `->weekly();` | Run the task every Sunday at 00:00. |
|
||||
| `->weeklyOn(1, '8:00');` | Run the task every week on Monday at 8:00. |
|
||||
| `->monthly();` | Run the task on the first day of every month at 00:00. |
|
||||
| `->monthlyOn(4, '15:00');` | Run the task every month on the 4th at 15:00. |
|
||||
| `->twiceMonthly(1, 16, '13:00');` | Run the task monthly on the 1st and 16th at 13:00. |
|
||||
| `->lastDayOfMonth('15:00');` | Run the task on the last day of the month at 15:00. |
|
||||
| `->quarterly();` | Run the task on the first day of every quarter at 00:00. |
|
||||
| `->quarterlyOn(4, '14:00');` | Run the task every quarter on the 4th at 14:00. |
|
||||
| `->yearly();` | Run the task on the first day of every year at 00:00. |
|
||||
| `->yearlyOn(6, 1, '17:00');` | Run the task every year on June 1st at 17:00. |
|
||||
| `->timezone('America/New_York');` | Set the timezone for the task. |
|
||||
|------------------------------------|----------------------------------------------------------|
|
||||
|
||||
```
|
||||
|
||||
These methods may be combined with additional constraints to create even more finely tuned schedules that only run on
|
||||
certain days of the week. For example, you may schedule a command to run weekly on Monday:
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
// Run once per week on Monday at 1 PM...
|
||||
Schedule::call(function () {
|
||||
// ...
|
||||
})->weekly()->mondays()->at('13:00');
|
||||
|
||||
// Run hourly from 8 AM to 5 PM on weekdays...
|
||||
Schedule::command('foo')
|
||||
->weekdays()
|
||||
->hourly()
|
||||
->timezone('America/Chicago')
|
||||
->between('8:00', '17:00');
|
||||
|
||||
A list of additional schedule constraints may be found below:
|
||||
|
||||
```
|
||||
|------------------------------------------|--------------------------------------------------------|
|
||||
| Method | Description |
|
||||
|------------------------------------------|--------------------------------------------------------|
|
||||
| `->weekdays();` | Limit the task to weekdays. |
|
||||
| `->weekends();` | Limit the task to weekends. |
|
||||
| `->sundays();` | Limit the task to Sunday. |
|
||||
| `->mondays();` | Limit the task to Monday. |
|
||||
| `->tuesdays();` | Limit the task to Tuesday. |
|
||||
| `->wednesdays();` | Limit the task to Wednesday. |
|
||||
| `->thursdays();` | Limit the task to Thursday. |
|
||||
| `->fridays();` | Limit the task to Friday. |
|
||||
| `->saturdays();` | Limit the task to Saturday. |
|
||||
| `->days(array\|mixed);` | Limit the task to specific days. |
|
||||
| `->between($startTime, $endTime);` | Limit the task to run between start and end times. |
|
||||
| `->unlessBetween($startTime, $endTime);` | Limit the task to not run between start and end times. |
|
||||
| `->when(Closure);` | Limit the task based on a truth test. |
|
||||
| `->environments($env);` | Limit the task to specific environments. |
|
||||
|------------------------------------------|--------------------------------------------------------|
|
||||
|
||||
```
|
||||
|
||||
<a name="day-constraints"></a>
|
||||
|
||||
#### Day Constraints
|
||||
|
||||
The `days` method may be used to limit the execution of a task to specific days of the week. For example, you may
|
||||
schedule a command to run hourly on Sundays and Wednesdays:
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->hourly()
|
||||
->days([0, 3]);
|
||||
|
||||
Alternatively, you may use the constants available on the `Illuminate\Console\Scheduling\Schedule` class when defining
|
||||
the days on which a task should run:
|
||||
|
||||
use Illuminate\Support\Facades;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
Facades\Schedule::command('emails:send')
|
||||
->hourly()
|
||||
->days([Schedule::SUNDAY, Schedule::WEDNESDAY]);
|
||||
|
||||
<a name="between-time-constraints"></a>
|
||||
|
||||
#### Between Time Constraints
|
||||
|
||||
The `between` method may be used to limit the execution of a task based on the time of day:
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->hourly()
|
||||
->between('7:00', '22:00');
|
||||
|
||||
Similarly, the `unlessBetween` method can be used to exclude the execution of a task for a period of time:
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->hourly()
|
||||
->unlessBetween('23:00', '4:00');
|
||||
|
||||
<a name="truth-test-constraints"></a>
|
||||
|
||||
#### Truth Test Constraints
|
||||
|
||||
The `when` method may be used to limit the execution of a task based on the result of a given truth test. In other
|
||||
words, if the given closure returns `true`, the task will execute as long as no other constraining conditions prevent
|
||||
the task from running:
|
||||
|
||||
Schedule::command('emails:send')->daily()->when(function () {
|
||||
return true;
|
||||
});
|
||||
|
||||
The `skip` method may be seen as the inverse of `when`. If the `skip` method returns `true`, the scheduled task will not
|
||||
be executed:
|
||||
|
||||
Schedule::command('emails:send')->daily()->skip(function () {
|
||||
return true;
|
||||
});
|
||||
|
||||
When using chained `when` methods, the scheduled command will only execute if all `when` conditions return `true`.
|
||||
|
||||
<a name="environment-constraints"></a>
|
||||
|
||||
#### Environment Constraints
|
||||
|
||||
The `environments` method may be used to execute tasks only on the given environments (as defined by the
|
||||
`APP_ENV` [environment variable](/docs/{{version}}/configuration#environment-configuration)):
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->daily()
|
||||
->environments(['staging', 'production']);
|
||||
|
||||
<a name="timezones"></a>
|
||||
|
||||
### Timezones
|
||||
|
||||
Using the `timezone` method, you may specify that a scheduled task's time should be interpreted within a given timezone:
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('report:generate')
|
||||
->timezone('America/New_York')
|
||||
->at('2:00')
|
||||
|
||||
If you are repeatedly assigning the same timezone to all of your scheduled tasks, you can specify which timezone should
|
||||
be assigned to all schedules by defining a `schedule_timezone` option within your application's `app` configuration
|
||||
file:
|
||||
|
||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||
|
||||
'schedule_timezone' => 'America/Chicago',
|
||||
|
||||
> [!WARNING]
|
||||
> Remember that some timezones utilize daylight savings time. When daylight saving time changes occur, your scheduled
|
||||
> task may run twice or even not run at all. For this reason, we recommend avoiding timezone scheduling when possible.
|
||||
|
||||
<a name="preventing-task-overlaps"></a>
|
||||
|
||||
### Preventing Task Overlaps
|
||||
|
||||
By default, scheduled tasks will be run even if the previous instance of the task is still running. To prevent this, you
|
||||
may use the `withoutOverlapping` method:
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('emails:send')->withoutOverlapping();
|
||||
|
||||
In this example, the `emails:send` [Artisan command](/docs/{{version}}/artisan) will be run every minute if it is not
|
||||
already running. The `withoutOverlapping` method is especially useful if you have tasks that vary drastically in their
|
||||
execution time, preventing you from predicting exactly how long a given task will take.
|
||||
|
||||
If needed, you may specify how many minutes must pass before the "without overlapping" lock expires. By default, the
|
||||
lock will expire after 24 hours:
|
||||
|
||||
Schedule::command('emails:send')->withoutOverlapping(10);
|
||||
|
||||
Behind the scenes, the `withoutOverlapping` method utilizes your application's [cache](/docs/{{version}}/cache) to
|
||||
obtain locks. If necessary, you can clear these cache locks using the `schedule:clear-cache` Artisan command. This is
|
||||
typically only necessary if a task becomes stuck due to an unexpected server problem.
|
||||
|
||||
<a name="running-tasks-on-one-server"></a>
|
||||
|
||||
### Running Tasks on One Server
|
||||
|
||||
> [!WARNING]
|
||||
> To utilize this feature, your application must be using the `database`, `memcached`, `dynamodb`, or `redis` cache
|
||||
> driver as your application's default cache driver. In addition, all servers must be communicating with the same
|
||||
> central
|
||||
> cache server.
|
||||
|
||||
If your application's scheduler is running on multiple servers, you may limit a scheduled job to only execute on a
|
||||
single server. For instance, assume you have a scheduled task that generates a new report every Friday night. If the
|
||||
task scheduler is running on three worker servers, the scheduled task will run on all three servers and generate the
|
||||
report three times. Not good!
|
||||
|
||||
To indicate that the task should run on only one server, use the `onOneServer` method when defining the scheduled task.
|
||||
The first server to obtain the task will secure an atomic lock on the job to prevent other servers from running the same
|
||||
task at the same time:
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('report:generate')
|
||||
->fridays()
|
||||
->at('17:00')
|
||||
->onOneServer();
|
||||
|
||||
<a name="naming-unique-jobs"></a>
|
||||
|
||||
#### Naming Single Server Jobs
|
||||
|
||||
Sometimes you may need to schedule the same job to be dispatched with different parameters, while still instructing
|
||||
Laravel to run each permutation of the job on a single server. To accomplish this, you may assign each schedule
|
||||
definition a unique name via the `name` method:
|
||||
|
||||
```php
|
||||
Schedule::job(new CheckUptime('https://laravel.com'))
|
||||
->name('check_uptime:laravel.com')
|
||||
->everyFiveMinutes()
|
||||
->onOneServer();
|
||||
|
||||
Schedule::job(new CheckUptime('https://vapor.laravel.com'))
|
||||
->name('check_uptime:vapor.laravel.com')
|
||||
->everyFiveMinutes()
|
||||
->onOneServer();
|
||||
```
|
||||
|
||||
Similarly, scheduled closures must be assigned a name if they are intended to be run on one server:
|
||||
|
||||
```php
|
||||
Schedule::call(fn () => User::resetApiRequestCount())
|
||||
->name('reset-api-request-count')
|
||||
->daily()
|
||||
->onOneServer();
|
||||
```
|
||||
|
||||
<a name="background-tasks"></a>
|
||||
|
||||
### Background Tasks
|
||||
|
||||
By default, multiple tasks scheduled at the same time will execute sequentially based on the order they are defined in
|
||||
your `schedule` method. If you have long-running tasks, this may cause subsequent tasks to start much later than
|
||||
anticipated. If you would like to run tasks in the background so that they may all run simultaneously, you may use the
|
||||
`runInBackground` method:
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('analytics:report')
|
||||
->daily()
|
||||
->runInBackground();
|
||||
|
||||
> [!WARNING]
|
||||
> The `runInBackground` method may only be used when scheduling tasks via the `command` and `exec` methods.
|
||||
|
||||
<a name="maintenance-mode"></a>
|
||||
|
||||
### Maintenance Mode
|
||||
|
||||
Your application's scheduled tasks will not run when the application is
|
||||
in [maintenance mode](/docs/{{version}}/configuration#maintenance-mode), since we don't want your tasks to interfere
|
||||
with any unfinished maintenance you may be performing on your server. However, if you would like to force a task to run
|
||||
even in maintenance mode, you may call the `evenInMaintenanceMode` method when defining the task:
|
||||
|
||||
Schedule::command('emails:send')->evenInMaintenanceMode();
|
||||
|
||||
<a name="schedule-groups"></a>
|
||||
|
||||
### Schedule Groups
|
||||
|
||||
When defining multiple scheduled tasks with similar configurations, you can use Laravel’s task grouping feature to avoid
|
||||
repeating the same settings for each task. Grouping tasks simplifies your code and ensures consistency across related
|
||||
tasks.
|
||||
|
||||
To create a group of scheduled tasks, invoke the desired task configuration methods, followed by the `group` method. The
|
||||
`group` method accepts a closure that is responsible for defining the tasks that share the specified configuration:
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::daily()
|
||||
->onOneServer()
|
||||
->timezone('America/New_York')
|
||||
->group(function () {
|
||||
Schedule::command('emails:send --force');
|
||||
Schedule::command('emails:prune');
|
||||
});
|
||||
```
|
||||
|
||||
<a name="running-the-scheduler"></a>
|
||||
|
||||
## Running the Scheduler
|
||||
|
||||
Now that we have learned how to define scheduled tasks, let's discuss how to actually run them on our server. The
|
||||
`schedule:run` Artisan command will evaluate all of your scheduled tasks and determine if they need to run based on the
|
||||
server's current time.
|
||||
|
||||
So, when using Laravel's scheduler, we only need to add a single cron configuration entry to our server that runs the
|
||||
`schedule:run` command every minute. If you do not know how to add cron entries to your server, consider using a service
|
||||
such as [Laravel Forge](https://forge.laravel.com) which can manage the cron entries for you:
|
||||
|
||||
```shell
|
||||
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
<a name="sub-minute-scheduled-tasks"></a>
|
||||
|
||||
### Sub-Minute Scheduled Tasks
|
||||
|
||||
On most operating systems, cron jobs are limited to running a maximum of once per minute. However, Laravel's scheduler
|
||||
allows you to schedule tasks to run at more frequent intervals, even as often as once per second:
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::call(function () {
|
||||
DB::table('recent_users')->delete();
|
||||
})->everySecond();
|
||||
|
||||
When sub-minute tasks are defined within your application, the `schedule:run` command will continue running until the
|
||||
end of the current minute instead of exiting immediately. This allows the command to invoke all required sub-minute
|
||||
tasks throughout the minute.
|
||||
|
||||
Since sub-minute tasks that take longer than expected to run could delay the execution of later sub-minute tasks, it is
|
||||
recommended that all sub-minute tasks dispatch queued jobs or background commands to handle the actual task processing:
|
||||
|
||||
use App\Jobs\DeleteRecentUsers;
|
||||
|
||||
Schedule::job(new DeleteRecentUsers)->everyTenSeconds();
|
||||
|
||||
Schedule::command('users:delete')->everyTenSeconds()->runInBackground();
|
||||
|
||||
<a name="interrupting-sub-minute-tasks"></a>
|
||||
|
||||
#### Interrupting Sub-Minute Tasks
|
||||
|
||||
As the `schedule:run` command runs for the entire minute of invocation when sub-minute tasks are defined, you may
|
||||
sometimes need to interrupt the command when deploying your application. Otherwise, an instance of the `schedule:run`
|
||||
command that is already running would continue using your application's previously deployed code until the current
|
||||
minute ends.
|
||||
|
||||
To interrupt in-progress `schedule:run` invocations, you may add the `schedule:interrupt` command to your application's
|
||||
deployment script. This command should be invoked after your application is finished deploying:
|
||||
|
||||
```shell
|
||||
php artisan schedule:interrupt
|
||||
```
|
||||
|
||||
<a name="running-the-scheduler-locally"></a>
|
||||
|
||||
### Running the Scheduler Locally
|
||||
|
||||
Typically, you would not add a scheduler cron entry to your local development machine. Instead, you may use the
|
||||
`schedule:work` Artisan command. This command will run in the foreground and invoke the scheduler every minute until you
|
||||
terminate the command. When sub-minute tasks are defined, the scheduler will continue running within each minute to
|
||||
process those tasks:
|
||||
|
||||
```shell
|
||||
php artisan schedule:work
|
||||
```
|
||||
|
||||
<a name="task-output"></a>
|
||||
|
||||
## Task Output
|
||||
|
||||
The Laravel scheduler provides several convenient methods for working with the output generated by scheduled tasks.
|
||||
First, using the `sendOutputTo` method, you may send the output to a file for later inspection:
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->daily()
|
||||
->sendOutputTo($filePath);
|
||||
|
||||
If you would like to append the output to a given file, you may use the `appendOutputTo` method:
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->daily()
|
||||
->appendOutputTo($filePath);
|
||||
|
||||
Using the `emailOutputTo` method, you may email the output to an email address of your choice. Before emailing the
|
||||
output of a task, you should configure Laravel's [email services](/docs/{{version}}/mail):
|
||||
|
||||
Schedule::command('report:generate')
|
||||
->daily()
|
||||
->sendOutputTo($filePath)
|
||||
->emailOutputTo('taylor@example.com');
|
||||
|
||||
If you only want to email the output if the scheduled Artisan or system command terminates with a non-zero exit code,
|
||||
use the `emailOutputOnFailure` method:
|
||||
|
||||
Schedule::command('report:generate')
|
||||
->daily()
|
||||
->emailOutputOnFailure('taylor@example.com');
|
||||
|
||||
> [!WARNING]
|
||||
> The `emailOutputTo`, `emailOutputOnFailure`, `sendOutputTo`, and `appendOutputTo` methods are exclusive to the
|
||||
`command` and `exec` methods.
|
||||
|
||||
<a name="task-hooks"></a>
|
||||
|
||||
## Task Hooks
|
||||
|
||||
Using the `before` and `after` methods, you may specify code to be executed before and after the scheduled task is
|
||||
executed:
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->daily()
|
||||
->before(function () {
|
||||
// The task is about to execute...
|
||||
})
|
||||
->after(function () {
|
||||
// The task has executed...
|
||||
});
|
||||
|
||||
The `onSuccess` and `onFailure` methods allow you to specify code to be executed if the scheduled task succeeds or
|
||||
fails. A failure indicates that the scheduled Artisan or system command terminated with a non-zero exit code:
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->daily()
|
||||
->onSuccess(function () {
|
||||
// The task succeeded...
|
||||
})
|
||||
->onFailure(function () {
|
||||
// The task failed...
|
||||
});
|
||||
|
||||
If output is available from your command, you may access it in your `after`, `onSuccess` or `onFailure` hooks by
|
||||
type-hinting an `Illuminate\Support\Stringable` instance as the `$output` argument of your hook's closure definition:
|
||||
|
||||
use Illuminate\Support\Stringable;
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->daily()
|
||||
->onSuccess(function (Stringable $output) {
|
||||
// The task succeeded...
|
||||
})
|
||||
->onFailure(function (Stringable $output) {
|
||||
// The task failed...
|
||||
});
|
||||
|
||||
<a name="pinging-urls"></a>
|
||||
|
||||
#### Pinging URLs
|
||||
|
||||
Using the `pingBefore` and `thenPing` methods, the scheduler can automatically ping a given URL before or after a task
|
||||
is executed. This method is useful for notifying an external service, such as [Envoyer](https://envoyer.io), that your
|
||||
scheduled task is beginning or has finished execution:
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->daily()
|
||||
->pingBefore($url)
|
||||
->thenPing($url);
|
||||
|
||||
The `pingOnSuccess` and `pingOnFailure` methods may be used to ping a given URL only if the task succeeds or fails. A
|
||||
failure indicates that the scheduled Artisan or system command terminated with a non-zero exit code:
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->daily()
|
||||
->pingOnSuccess($successUrl)
|
||||
->pingOnFailure($failureUrl);
|
||||
|
||||
The `pingBeforeIf`,`thenPingIf`,`pingOnSuccessIf`, and `pingOnFailureIf` methods may be used to ping a given URL only if
|
||||
a given condition is `true`:
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->daily()
|
||||
->pingBeforeIf($condition, $url)
|
||||
->thenPingIf($condition, $url);
|
||||
|
||||
Schedule::command('emails:send')
|
||||
->daily()
|
||||
->pingOnSuccessIf($condition, $successUrl)
|
||||
->pingOnFailureIf($condition, $failureUrl);
|
||||
|
||||
<a name="events"></a>
|
||||
|
||||
## Events
|
||||
|
||||
Laravel dispatches a variety of [events](/docs/{{version}}/events) during the scheduling process. You
|
||||
may [define listeners](/docs/{{version}}/events) for any of the following events:
|
||||
|
||||
```
|
||||
|-------------------------------------------------------------|
|
||||
| Event Name |
|
||||
|-------------------------------------------------------------|
|
||||
| `Illuminate\Console\Events\ScheduledTaskStarting` |
|
||||
| `Illuminate\Console\Events\ScheduledTaskFinished` |
|
||||
| `Illuminate\Console\Events\ScheduledBackgroundTaskFinished` |
|
||||
| `Illuminate\Console\Events\ScheduledTaskSkipped` |
|
||||
| `Illuminate\Console\Events\ScheduledTaskFailed` |
|
||||
|-------------------------------------------------------------|
|
||||
```
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
### The Spares Module
|
||||
|
||||
This details the operation and key highlights and developer notes for the Maximo ( Spares) module
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
### SSHD Server
|
||||
|
||||
to restart the SSH Server on AS400 use in 5250 Console
|
||||
|
||||
```
|
||||
STRTCPSVR *SSHD
|
||||
|
||||
```
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
# Stop Treating Your Blade Files Like Trash Bins. Give Them Contracts And Structure
|
||||
|
||||
Are you a Laravel developer? Do you love Blade? It’s simple, expressive, and fits naturally with the framework. But
|
||||
let’s be honest: most projects eventually turn their Blade files into dumping grounds for random variables. The
|
||||
Controllers pass loose data, views end up receiving unstructured data, partials rely on implicit assumptions, and the
|
||||
whole setup becomes fragile and error-prone.
|
||||
|
||||
## The Result?
|
||||
|
||||
- No autocomplete.
|
||||
- Runtime errors from typos.
|
||||
- Partial views with zero contracts.
|
||||
- A mess that only grows as the app scales.
|
||||
|
||||
It doesn’t have to be this way. Blade files deserve the same contracts and structure we give to APIs, database models,
|
||||
or services. With a few clear practices, you can bring discipline, contracts, and structure to Blade.
|
||||
|
||||
This article will show you the complete approach:
|
||||
|
||||
1. The problem of passing data as arrays or variables in views — solved by introducing ViewModels as contracts.
|
||||
2. Enable Autocomplete in Blade so your IDE actually helps.
|
||||
3. Annotation to views and partials bound to their own ViewModels.
|
||||
4. Lock down data structures with DTO classes for strict typing.
|
||||
5. Establish Principles for sustainable Blade development.
|
||||
|
||||
**Remember, we are only targeting Blade for now.** Not Twig or Antlr.
|
||||
Grab a coffee — this is a long read. But by the end, you’ll never look at Blade the same way again.
|
||||
|
||||
---
|
||||
|
||||
## The Crime Scene: What’s Wrong With “Array Soup” in Blade
|
||||
|
||||
Here’s the standard Laravel approach we’ve all seen in controllers:
|
||||
|
||||
```php
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$products = Product::all();
|
||||
$categories = Category::all();
|
||||
$brand = Brand::all();
|
||||
$title = 'Products';
|
||||
|
||||
return view('products.index', compact('products','categories','brands','title') );
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or maybe:
|
||||
|
||||
```php
|
||||
return view('products.index', [
|
||||
'title' => 'Products',
|
||||
'products' => Product::all(),
|
||||
'categories' => Category::all(),
|
||||
'brand' => Brand::all(),
|
||||
]);
|
||||
```
|
||||
|
||||
Or a slightly cleaner:
|
||||
|
||||
```php
|
||||
$data = [
|
||||
'title' => 'Products',
|
||||
'products' => Product::all(),
|
||||
'categories' => Category::all(),
|
||||
'brand' => Brand::all(),
|
||||
];
|
||||
|
||||
return view('products.index', $data);
|
||||
```
|
||||
|
||||
And here is how we do it for partials:
|
||||
|
||||
```blade
|
||||
@foreach ($products as $product)
|
||||
@include('products.partials.product')
|
||||
@endforeach
|
||||
|
||||
// or pass some additional data
|
||||
@foreach ($products as $product)
|
||||
@include('products.partials.product', ['editable' => $editable])
|
||||
@endforeach
|
||||
```
|
||||
|
||||
And finally for components:
|
||||
|
||||
```blade
|
||||
@foreach ($products as $product)
|
||||
<x-product-row :name="$product->name" :price ="$product->price" />
|
||||
@endforeach
|
||||
```
|
||||
|
||||
### The Old Lovely Blade
|
||||
|
||||
Here’s how it works well:
|
||||
|
||||
```blade
|
||||
<h1>{{ $title }}</h1>
|
||||
|
||||
<ul>
|
||||
@foreach ($products as $product)
|
||||
<li>{{ $product->name }} ({{ $product->price }})</li>
|
||||
{{-- or partial --}}
|
||||
@include('products.partials.product')
|
||||
{{-- or component --}}
|
||||
<x-product-row :name="$product->name" :price ="$product->price" />
|
||||
@endforeach
|
||||
</ul>
|
||||
```
|
||||
|
||||
But ask yourself:
|
||||
|
||||
- Do you remember all the variables passed to your views?
|
||||
- Do you get autocomplete in Blade to help you catch typos?
|
||||
- What about partials and includes — are you sure the data they rely on is really there?
|
||||
- If a teammate edits the controller, how do you even know the view won’t silently break?
|
||||
- How many times have you opened a Blade file and had no clue what’s in scope?
|
||||
|
||||
---
|
||||
|
||||
## 1. Introducing ViewModels: Contracts for Your Views
|
||||
|
||||
Instead of throwing arrays around, define a ViewModel — a simple DTO class. It declares exactly what data is expected.
|
||||
|
||||
```php
|
||||
namespace App\ViewModels;
|
||||
|
||||
class ProductsViewModel
|
||||
{
|
||||
public string $title;
|
||||
public array $products;
|
||||
public array $categories;
|
||||
public array $brand;
|
||||
}
|
||||
```
|
||||
|
||||
And in your controller:
|
||||
|
||||
```php
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$viewModel = new ProductsViewModel();
|
||||
|
||||
$viewModel->title = 'Products';
|
||||
$viewModel->products = Product::all();
|
||||
$viewModel->categories = Category::all();
|
||||
$viewModel->brands = Brand::all();
|
||||
|
||||
return view('products.index', ['model' => $viewModel]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Suggestion: Always pass view‑model with name `model` for consistency. This will help in autocomplete.
|
||||
|
||||
Now the Blade file might look like:
|
||||
|
||||
```blade
|
||||
<h1>{{ $model->title }}</h1>
|
||||
<ul>
|
||||
@foreach ($model->products as $product)
|
||||
<li>{{ $product->name }} ({{ $product->price }})</li>
|
||||
{{-- or partial --}}
|
||||
@include('products.partials.product',['model' => $product])
|
||||
{{-- or component --}}
|
||||
<x-product-row :model="$product" />
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
@include('partials.brands', ['model' => $model->brands])
|
||||
@include('partials.categories', ['model' => $model->categories])
|
||||
```
|
||||
|
||||
Again, the suggestion: for partials and components, always pass data with name `$model` for consistency to aid
|
||||
autocomplete.
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- The contract is explicit.
|
||||
- Adding or removing data requires updating the ViewModel.
|
||||
- Each view, partial, or component has a single data access point `$model`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Installing Extension for Autocomplete
|
||||
|
||||
This does not require code setup, but you’ll need to install a plugin/extension in your IDE. The author uses VS Code and
|
||||
recommends **PHP Tools by DevSense** (also available for other editors).
|
||||
|
||||
If you use a different editor, find a similar extension — support for Blade autocomplete is key.
|
||||
|
||||
---
|
||||
|
||||
## 3. Enabling Autocomplete in Blade
|
||||
|
||||
Even with ViewModels, Blade doesn’t automatically know the type of `$model`. The trick is to annotate it in Blade views,
|
||||
partials, and components:
|
||||
|
||||
```blade
|
||||
{{-- views/products/index.blade.php --}}
|
||||
@php
|
||||
/** @var \App\ViewModels\ProductsViewModel $model */
|
||||
@endphp
|
||||
<ul>
|
||||
@foreach ($model->products as $product)
|
||||
<li>{{ $product->name }} ({{ $product->price }})</li>
|
||||
{{-- or partial --}}
|
||||
@include('products.partials.product')
|
||||
{{-- or component --}}
|
||||
<x-product-row :name="$product->name" :price="$product->price" />
|
||||
@endforeach
|
||||
</ul>
|
||||
```
|
||||
|
||||
In partials or components:
|
||||
|
||||
```blade
|
||||
{{-- views/products/partials/product.blade.php --}}
|
||||
@php
|
||||
/** @var \App\Models\Product $model */
|
||||
@endphp
|
||||
|
||||
<span>
|
||||
{{ $model->name }}
|
||||
</span>
|
||||
```
|
||||
|
||||
And in components:
|
||||
|
||||
```blade
|
||||
{{-- views/components/product.blade.php --}}
|
||||
@php
|
||||
/** @var \App\View\Components\Product $model */
|
||||
@endphp
|
||||
|
||||
<span>
|
||||
{{ $model->name }}
|
||||
</span>
|
||||
```
|
||||
|
||||
Now, in an IDE like VS Code, typing `$model->` will give you real autocomplete suggestions.
|
||||
|
||||
This is a game-changer:
|
||||
|
||||
- No more guessing property names.
|
||||
- Mistyped variables get caught instantly.
|
||||
- New developers know exactly what’s available in the view.
|
||||
|
||||
---
|
||||
|
||||
## 4. Enforce Types at Runtime
|
||||
|
||||
Since now we pass ViewModels into views, partials, or components and define annotations, let’s enforce Blade to accept
|
||||
only specific types. The author adds:
|
||||
|
||||
1. **TypedViewFactory** – A custom view factory that parses `@var` declarations at the top of Blade files and enforces
|
||||
them.
|
||||
2. **TypedViewServiceProvider** – Wires everything into Laravel automatically.
|
||||
|
||||
If you declare a type in a Blade file, Laravel must pass the correct class, array, or collection; otherwise, it throws
|
||||
an exception.
|
||||
|
||||
Example:
|
||||
|
||||
```blade
|
||||
{{-- products/index.blade.php --}}
|
||||
@php /** @var App\ViewModels\ProductsViewModel $model */ @endphp
|
||||
<h1>{{ $model->title }}</h1>
|
||||
```
|
||||
|
||||
Only `ProductsViewModel` works—anything else throws an exception.
|
||||
|
||||
Similarly for partials and components, the wrong type is no longer silently accepted; it fails loudly.
|
||||
|
||||
You can do the same for arrays and collections:
|
||||
|
||||
```blade
|
||||
@php
|
||||
/** @var App\Models\Product[] $model */
|
||||
@endphp
|
||||
|
||||
{{-- or --}}
|
||||
@php
|
||||
/** @var \Illuminate\Support\Collection<App\Models\Product> $model */
|
||||
@endphp
|
||||
```
|
||||
|
||||
Wrong types cause errors rather than silent bugs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Going Fully Typed With DTO Classes
|
||||
|
||||
So far, we’ve wrapped data in ViewModels. But what about the data itself? Passing raw Eloquent models to views can be
|
||||
risky — they carry too much baggage and autocomplete may expose a jungle of methods and properties.
|
||||
|
||||
Let’s create DTO classes to define exactly what your views need. (Note: components themselves can act as DTOs, so
|
||||
sometimes you don’t need separate DTOs.)
|
||||
|
||||
### BaseDTO
|
||||
|
||||
```php
|
||||
namespace App\DTO;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
abstract class BaseDTO
|
||||
{
|
||||
public function __construct(array $data = [])
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
if (!property_exists($this, $key)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Property '{$key}' is not defined in " . static::class
|
||||
);
|
||||
}
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
public function __get($name)
|
||||
{
|
||||
throw new InvalidArgumentException(
|
||||
"Tried to access undefined property '{$name}' on " . static::class
|
||||
);
|
||||
}
|
||||
public function __set($name, $value)
|
||||
{
|
||||
throw new InvalidArgumentException(
|
||||
"Tried to set undefined property '{$name}' on " . static::class
|
||||
);
|
||||
}
|
||||
public static function columns(): array
|
||||
{
|
||||
return array_map(
|
||||
fn($prop) => $prop->getName(),
|
||||
(new \ReflectionClass(static::class))->getProperties()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SimpleProduct DTO
|
||||
|
||||
```php
|
||||
namespace App\DTO\Product;
|
||||
|
||||
use App\DTO\BaseDTO;
|
||||
|
||||
class SimpleProduct extends BaseDTO
|
||||
{
|
||||
public string $name;
|
||||
public string $image;
|
||||
public float $price;
|
||||
public int $stock;
|
||||
}
|
||||
```
|
||||
|
||||
In the controller:
|
||||
|
||||
```php
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$viewModel = new ProductsViewModel();
|
||||
|
||||
$viewModel->products = Product::select(SimpleProduct::columns())->get();
|
||||
// other assignments...
|
||||
return view('products.index', ['model' => $viewModel]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the partial:
|
||||
|
||||
```blade
|
||||
{{-- views/products/partials/product.blade.php --}}
|
||||
@php
|
||||
/** @var \App\DTO\Product\SimpleProduct $model */
|
||||
@endphp
|
||||
|
||||
<span>
|
||||
{{ $model->name }}
|
||||
</span>
|
||||
```
|
||||
|
||||
Autocomplete now shows only DTO properties.
|
||||
|
||||
#### Caveats & Tips
|
||||
|
||||
- If you only select `DTO::columns()` in your query and still pass raw models to Blade, IDE autocomplete works via
|
||||
annotations—but at runtime Blade can still access everything.
|
||||
- For stricter discipline, wrap results into DTO instances.
|
||||
- Schema Drift Protection: if a DTO includes a property not in the table, MySQL will error, keeping your DTOs in sync.
|
||||
- You lose Eloquent helpers, relations, and accessors. Do transformations before mapping to DTOs.
|
||||
- Mapping overhead is minimal: a base DTO + mapper makes it clean and efficient.
|
||||
|
||||
Example mapper:
|
||||
|
||||
```php
|
||||
class DTOMapper
|
||||
{
|
||||
public static function map(object $source, string $dtoClass): object
|
||||
{
|
||||
$dtoReflection = new ReflectionClass($dtoClass);
|
||||
$properties = $dtoReflection->getProperties();
|
||||
|
||||
$args = [];
|
||||
|
||||
foreach ($properties as $property) {
|
||||
$name = $property->getName();
|
||||
if (isset($source->$name)) {
|
||||
$args[$name] = $source->$name;
|
||||
}
|
||||
}
|
||||
|
||||
return new $dtoClass(...$args);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```php
|
||||
$dto = DTOMapper::map($product, SimpleProduct::class);
|
||||
$dtos = $products->map(fn($p) => DTOMapper::map($p, ProductDTO::class));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Principles of Structured Blade
|
||||
|
||||
This approach holds only if you enforce ground rules:
|
||||
|
||||
1. **No untyped arrays in views** — every view receives a single typed contract.
|
||||
2. **One ViewModel per Blade file** — keep mental load low.
|
||||
3. **Partials and components need contracts too** — no freeloaders.
|
||||
4. **Always annotate with `@var`** — autocomplete is non‑negotiable.
|
||||
5. **Always pass data as `model` for consistency** — every blade view has a single entry point.
|
||||
6. **Business logic stays out of Blade views** — data shaping and conditions belong in ViewModels.
|
||||
7. **Use DTOs to avoid Eloquent jungle** — views see pure, predictable DTOs.
|
||||
8. **Strict access, fail loudly** — undefined properties in ViewModel should throw, not silently fail.
|
||||
|
||||
---
|
||||
|
||||
## Final Word
|
||||
|
||||
Blade isn’t broken. The way most developers use it is.
|
||||
|
||||
Treating Blade files as unstructured trash bins creates brittle, unmaintainable code. Adding ViewModels, autocomplete,
|
||||
contracts for partials, and DTOs gives your views the same level of professionalism as the rest of your codebase.
|
||||
|
||||
This isn’t overkill. It’s discipline. And discipline is what separates fragile projects from sustainable ones.
|
||||
|
||||
So stop passing arrays. Start passing contracts. Give your Blade files the structure they deserve.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Documentation\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $documentation_document_id
|
||||
* @property string $disk
|
||||
* @property string $path
|
||||
* @property string $original_name
|
||||
* @property string|null $mime_type
|
||||
* @property int|null $size
|
||||
*/
|
||||
class DocumentationAttachment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'documentation_document_id',
|
||||
'disk',
|
||||
'path',
|
||||
'original_name',
|
||||
'mime_type',
|
||||
'size',
|
||||
'uploaded_by',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'size' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Owning document.
|
||||
*
|
||||
* @return BelongsTo<DocumentationDocument, DocumentationAttachment>
|
||||
*/
|
||||
public function document(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentationDocument::class, 'documentation_document_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a readable file size label.
|
||||
*/
|
||||
public function getSizeLabelAttribute(): string
|
||||
{
|
||||
$bytes = (int) $this->size;
|
||||
|
||||
if ($bytes <= 0) {
|
||||
return 'Unknown size';
|
||||
}
|
||||
|
||||
if ($bytes < 1024) {
|
||||
return $bytes.' B';
|
||||
}
|
||||
|
||||
if ($bytes < 1048576) {
|
||||
return number_format($bytes / 1024, 1).' KB';
|
||||
}
|
||||
|
||||
return number_format($bytes / 1048576, 1).' MB';
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Documentation\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $slug
|
||||
* @property string|null $icon
|
||||
* @property string $colour
|
||||
* @property string|null $description
|
||||
* @property int $sort_order
|
||||
* @property bool $is_active
|
||||
*/
|
||||
class DocumentationCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'icon',
|
||||
'colour',
|
||||
'description',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Bootstrap the model.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (DocumentationCategory $category): void {
|
||||
if (blank($category->slug)) {
|
||||
$category->slug = Str::slug($category->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents assigned to this category.
|
||||
*
|
||||
* @return HasMany<DocumentationDocument>
|
||||
*/
|
||||
public function documents(): HasMany
|
||||
{
|
||||
return $this->hasMany(DocumentationDocument::class, 'documentation_category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Active category scope.
|
||||
*
|
||||
* @param Builder<DocumentationCategory> $query
|
||||
* @return Builder<DocumentationCategory>
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered category scope.
|
||||
*
|
||||
* @param Builder<DocumentationCategory> $query
|
||||
* @return Builder<DocumentationCategory>
|
||||
*/
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('name');
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Documentation\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int|null $documentation_category_id
|
||||
* @property string $title
|
||||
* @property string $slug
|
||||
* @property string|null $summary
|
||||
* @property string|null $body
|
||||
* @property string $status
|
||||
* @property bool $is_featured
|
||||
* @property string $version
|
||||
* @property string|null $reference
|
||||
* @property-read string $rendered_body
|
||||
*/
|
||||
class DocumentationDocument extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'documentation_category_id',
|
||||
'title',
|
||||
'slug',
|
||||
'summary',
|
||||
'body',
|
||||
'status',
|
||||
'is_featured',
|
||||
'version',
|
||||
'reference',
|
||||
'attachment_disk',
|
||||
'attachment_path',
|
||||
'attachment_name',
|
||||
'attachment_mime',
|
||||
'attachment_size',
|
||||
'published_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'is_featured' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'attachment_size' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Bootstrap the model.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (DocumentationDocument $document): void {
|
||||
if (blank($document->slug)) {
|
||||
$document->slug = Str::slug($document->title);
|
||||
}
|
||||
|
||||
if ($document->status === self::STATUS_PUBLISHED && blank($document->published_at)) {
|
||||
$document->published_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Owning category.
|
||||
*
|
||||
* @return BelongsTo<DocumentationCategory, DocumentationDocument>
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentationCategory::class, 'documentation_category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Document attachments.
|
||||
*
|
||||
* @return HasMany<DocumentationAttachment>
|
||||
*/
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(DocumentationAttachment::class, 'documentation_document_id')->latest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Published document scope.
|
||||
*
|
||||
* @param Builder<DocumentationDocument> $query
|
||||
* @return Builder<DocumentationDocument>
|
||||
*/
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_PUBLISHED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured document scope.
|
||||
*
|
||||
* @param Builder<DocumentationDocument> $query
|
||||
* @return Builder<DocumentationDocument>
|
||||
*/
|
||||
public function scopeFeatured(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_featured', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the document body as safe Markdown HTML for display.
|
||||
*/
|
||||
public function getRenderedBodyAttribute(): string
|
||||
{
|
||||
$body = trim((string) $this->body);
|
||||
|
||||
if ($body === '') {
|
||||
return '<p>No document body has been added yet.</p>';
|
||||
}
|
||||
|
||||
if (method_exists(Str::class, 'markdown')) {
|
||||
return (string) Str::markdown($body, [
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return '<p>'.nl2br(e($body), false).'</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the attached file is a Markdown document.
|
||||
*/
|
||||
public function hasMarkdownAttachment(): bool
|
||||
{
|
||||
$name = strtolower((string) $this->attachment_name);
|
||||
$mime = strtolower((string) $this->attachment_mime);
|
||||
|
||||
return str_ends_with($name, '.md') || in_array($mime, ['text/markdown', 'text/x-markdown', 'text/plain'], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Documentation\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Documentation\Console\ImportDocumentationCommand;
|
||||
|
||||
final class DocumentationServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register module services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->register(RouteServiceProvider::class);
|
||||
|
||||
$this->mergeConfigFrom(
|
||||
module_path('Documentation', 'Config/config.php'),
|
||||
'documentation',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap module services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->registerViews();
|
||||
$this->registerCommands();
|
||||
$this->loadMigrationsFrom(module_path('Documentation', 'Database/Migrations'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register module console commands.
|
||||
*/
|
||||
private function registerCommands(): void
|
||||
{
|
||||
if (! $this->app->runningInConsole()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->commands([
|
||||
ImportDocumentationCommand::class,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register module views and anonymous components.
|
||||
*/
|
||||
private function registerViews(): void
|
||||
{
|
||||
$sourcePath = module_path('Documentation', 'Resources/views');
|
||||
$overridePath = resource_path('views/modules/documentation');
|
||||
|
||||
$this->publishes([
|
||||
$sourcePath => $overridePath,
|
||||
], ['views', 'documentation-module-views']);
|
||||
|
||||
$paths = [$sourcePath];
|
||||
|
||||
foreach (Config::get('view.paths', []) as $path) {
|
||||
if (is_dir($path.'/modules/documentation')) {
|
||||
array_unshift($paths, $path.'/modules/documentation');
|
||||
}
|
||||
}
|
||||
|
||||
$this->loadViewsFrom($paths, 'documentation');
|
||||
Blade::anonymousComponentPath($sourcePath.'/components', 'documentation');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Documentation\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The module namespace to assume when generating URLs to actions.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $moduleNamespace = 'Modules\Documentation\Http\Controllers';
|
||||
|
||||
/**
|
||||
* Called before routes are registered.
|
||||
*
|
||||
* Register any model bindings or pattern based filters.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
parent::boot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the routes for the application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function map()
|
||||
{
|
||||
$this->mapApiRoutes();
|
||||
|
||||
$this->mapWebRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "web" routes for the application.
|
||||
*
|
||||
* These routes all receive session state, CSRF protection, etc.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function mapWebRoutes()
|
||||
{
|
||||
Route::middleware('web')
|
||||
->group(module_path('Documentation', '/Routes/web.php'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "api" routes for the application.
|
||||
*
|
||||
* These routes are typically stateless.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function mapApiRoutes()
|
||||
{
|
||||
Route::prefix('api')
|
||||
->middleware('api')
|
||||
->group(module_path('Documentation', '/Routes/api.php'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
:root {
|
||||
--doc-ink: #18181b;
|
||||
--doc-muted: #71717a;
|
||||
--doc-line: #e4e4e7;
|
||||
--doc-panel: #ffffff;
|
||||
--doc-soft: #f4f4f5;
|
||||
--doc-brand: #2563eb;
|
||||
--doc-brand-dark: #1d4ed8;
|
||||
--doc-accent: #059669;
|
||||
--doc-danger: #dc2626;
|
||||
--doc-warning: #d97706;
|
||||
--doc-radius: 22px;
|
||||
--doc-shadow: 0 18px 55px rgba(15, 23, 42, .10);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--doc-ink: #f4f4f5;
|
||||
--doc-muted: #a1a1aa;
|
||||
--doc-line: #3f3f46;
|
||||
--doc-panel: #18181b;
|
||||
--doc-soft: #09090b;
|
||||
--doc-shadow: 0 18px 55px rgba(0, 0, 0, .28);
|
||||
}
|
||||
|
||||
body.documentation-module {
|
||||
margin: 0;
|
||||
background: #ffffff;
|
||||
color: var(--doc-ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
html.dark body.documentation-module {
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
.doc-shell {
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.doc-page {
|
||||
margin: 0 auto;
|
||||
max-width: 1800px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.doc-hero-wrap {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--doc-line);
|
||||
border-radius: 1.5rem;
|
||||
background: var(--doc-panel);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, .06);
|
||||
}
|
||||
|
||||
.doc-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.75rem 1.5rem;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #020617 0%, #0f172a 48%, #172554 100%);
|
||||
}
|
||||
|
||||
.doc-hero-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.doc-hero h1 {
|
||||
margin: .5rem 0 0;
|
||||
font-size: clamp(1.875rem, 4vw, 3rem);
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
letter-spacing: -.04em;
|
||||
}
|
||||
|
||||
.doc-eyebrow {
|
||||
margin: 0;
|
||||
color: #bfdbfe;
|
||||
text-transform: uppercase;
|
||||
font-size: .75rem;
|
||||
letter-spacing: .28em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doc-hero p {
|
||||
max-width: 48rem;
|
||||
margin: .5rem 0 0;
|
||||
color: rgba(219, 234, 254, .9);
|
||||
font-size: .875rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.doc-button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: .75rem;
|
||||
padding: .625rem .875rem;
|
||||
font-weight: 700;
|
||||
font-size: .875rem;
|
||||
line-height: 1.25rem;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color .15s ease, border-color .15s ease, transform .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
|
||||
.doc-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.doc-button.primary,
|
||||
.doc-button.register,
|
||||
.doc-button.dark {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, .22);
|
||||
}
|
||||
|
||||
.doc-button.primary:hover,
|
||||
.doc-button.register:hover,
|
||||
.doc-button.dark:hover {
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.doc-button.secondary,
|
||||
.doc-button.category,
|
||||
.doc-button.settings {
|
||||
background: #7c3aed;
|
||||
color: #ffffff;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 8px 18px rgba(124, 58, 237, .22);
|
||||
}
|
||||
|
||||
.doc-button.secondary:hover,
|
||||
.doc-button.category:hover,
|
||||
.doc-button.settings:hover {
|
||||
background: #6d28d9;
|
||||
border-color: #6d28d9;
|
||||
}
|
||||
|
||||
.doc-button.light,
|
||||
.doc-button.cancel,
|
||||
.doc-button.warning {
|
||||
background: #f59e0b;
|
||||
color: #ffffff;
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 8px 18px rgba(245, 158, 11, .22);
|
||||
}
|
||||
|
||||
.doc-button.light:hover,
|
||||
.doc-button.cancel:hover,
|
||||
.doc-button.warning:hover {
|
||||
background: #d97706;
|
||||
border-color: #d97706;
|
||||
}
|
||||
|
||||
.doc-button.success {
|
||||
background: #059669;
|
||||
color: #ffffff;
|
||||
border-color: #059669;
|
||||
box-shadow: 0 8px 18px rgba(5, 150, 105, .22);
|
||||
}
|
||||
|
||||
.doc-button.success:hover {
|
||||
background: #047857;
|
||||
border-color: #047857;
|
||||
}
|
||||
|
||||
.doc-button.danger {
|
||||
background: #dc2626;
|
||||
color: #ffffff;
|
||||
border-color: #dc2626;
|
||||
box-shadow: 0 8px 18px rgba(220, 38, 38, .22);
|
||||
}
|
||||
|
||||
.doc-button.danger:hover {
|
||||
background: #b91c1c;
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
background: var(--doc-panel);
|
||||
border: 1px solid var(--doc-line);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, .06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.doc-card-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--doc-line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.doc-card-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.doc-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.doc-grid.stats {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.doc-stat {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.doc-stat strong {
|
||||
display: block;
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
letter-spacing: -.04em;
|
||||
color: var(--doc-ink);
|
||||
}
|
||||
|
||||
.doc-stat span {
|
||||
color: var(--doc-muted);
|
||||
font-size: .75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
|
||||
.doc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.doc-table th {
|
||||
text-align: left;
|
||||
font-size: .75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: var(--doc-muted);
|
||||
padding: .75rem;
|
||||
border-bottom: 1px solid var(--doc-line);
|
||||
}
|
||||
|
||||
.doc-table td {
|
||||
padding: .875rem .75rem;
|
||||
border-bottom: 1px solid var(--doc-line);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.doc-table a,
|
||||
.doc-link {
|
||||
color: #1d4ed8;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
html.dark .doc-table a,
|
||||
html.dark .doc-link {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.doc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: .25rem .625rem;
|
||||
font-size: .75rem;
|
||||
font-weight: 800;
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.doc-badge.published {
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
|
||||
.doc-badge.draft {
|
||||
background: #f4f4f5;
|
||||
color: #52525b;
|
||||
border-color: #d4d4d8;
|
||||
}
|
||||
|
||||
.doc-badge.archived {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
html.dark .doc-badge {
|
||||
background: rgba(37, 99, 235, .18);
|
||||
color: #bfdbfe;
|
||||
border-color: rgba(59, 130, 246, .35);
|
||||
}
|
||||
|
||||
html.dark .doc-badge.published {
|
||||
background: rgba(5, 150, 105, .18);
|
||||
color: #a7f3d0;
|
||||
border-color: rgba(16, 185, 129, .35);
|
||||
}
|
||||
|
||||
html.dark .doc-badge.draft {
|
||||
background: rgba(113, 113, 122, .18);
|
||||
color: #d4d4d8;
|
||||
border-color: rgba(161, 161, 170, .30);
|
||||
}
|
||||
|
||||
html.dark .doc-badge.archived {
|
||||
background: rgba(220, 38, 38, .16);
|
||||
color: #fecaca;
|
||||
border-color: rgba(248, 113, 113, .32);
|
||||
}
|
||||
|
||||
.doc-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.doc-field.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.doc-label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
margin-bottom: .5rem;
|
||||
font-size: .875rem;
|
||||
color: var(--doc-ink);
|
||||
}
|
||||
|
||||
.doc-input,
|
||||
.doc-select,
|
||||
.doc-textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: .75rem;
|
||||
padding: .75rem .875rem;
|
||||
background: #ffffff;
|
||||
color: #18181b;
|
||||
font: inherit;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||
}
|
||||
|
||||
.doc-input:focus,
|
||||
.doc-select:focus,
|
||||
.doc-textarea:focus {
|
||||
outline: 2px solid rgba(37, 99, 235, .24);
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
html.dark .doc-input,
|
||||
html.dark .doc-select,
|
||||
html.dark .doc-textarea {
|
||||
background: #09090b;
|
||||
color: #f4f4f5;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.doc-textarea {
|
||||
min-height: 360px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.doc-editor {
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
html.dark .doc-editor {
|
||||
background: #09090b;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.doc-editor .doc-textarea {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.doc-editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .375rem;
|
||||
padding: .625rem;
|
||||
background: #f4f4f5;
|
||||
border-bottom: 1px solid var(--doc-line);
|
||||
}
|
||||
|
||||
html.dark .doc-editor-toolbar {
|
||||
background: #18181b;
|
||||
}
|
||||
|
||||
.doc-editor-toolbar button {
|
||||
border: 1px solid #d4d4d8;
|
||||
background: #ffffff;
|
||||
color: #18181b;
|
||||
border-radius: .625rem;
|
||||
padding: .45rem .625rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
html.dark .doc-editor-toolbar button {
|
||||
border-color: #3f3f46;
|
||||
background: #09090b;
|
||||
color: #f4f4f5;
|
||||
}
|
||||
|
||||
.doc-editor-toolbar button:hover,
|
||||
.doc-editor-toolbar button:focus {
|
||||
border-color: #2563eb;
|
||||
outline: 2px solid rgba(37, 99, 235, .20);
|
||||
}
|
||||
|
||||
.editor-help {
|
||||
padding: 0 .875rem .75rem;
|
||||
margin: -.25rem 0 0;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.doc-error {
|
||||
color: #dc2626;
|
||||
font-size: .8125rem;
|
||||
margin-top: .375rem;
|
||||
}
|
||||
|
||||
.doc-alert {
|
||||
border-radius: 1rem;
|
||||
padding: .875rem 1rem;
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
border: 1px solid #a7f3d0;
|
||||
font-size: .875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
html.dark .doc-alert {
|
||||
background: #022c22;
|
||||
color: #a7f3d0;
|
||||
border-color: #065f46;
|
||||
}
|
||||
|
||||
.doc-warning {
|
||||
border-radius: 1rem;
|
||||
padding: .875rem 1rem;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border: 1px solid #fcd34d;
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
html.dark .doc-warning {
|
||||
background: #451a03;
|
||||
color: #fef3c7;
|
||||
border-color: #92400e;
|
||||
}
|
||||
|
||||
.doc-filter {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 180px 220px auto;
|
||||
gap: .75rem;
|
||||
margin-bottom: 1.125rem;
|
||||
}
|
||||
|
||||
.doc-content {
|
||||
line-height: 1.75;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.doc-content h1,
|
||||
.doc-content h2,
|
||||
.doc-content h3 {
|
||||
line-height: 1.2;
|
||||
letter-spacing: -.025em;
|
||||
margin: 1.4em 0 .55em;
|
||||
}
|
||||
|
||||
.doc-content h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.doc-content h2 {
|
||||
font-size: 1.55rem;
|
||||
border-bottom: 1px solid var(--doc-line);
|
||||
padding-bottom: .35rem;
|
||||
}
|
||||
|
||||
.doc-content h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.doc-content p,
|
||||
.doc-content ul,
|
||||
.doc-content ol,
|
||||
.doc-content blockquote,
|
||||
.doc-content pre {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.doc-content code {
|
||||
background: #f1f5f9;
|
||||
border-radius: .4375rem;
|
||||
padding: .14rem .32rem;
|
||||
font-size: .92em;
|
||||
}
|
||||
|
||||
html.dark .doc-content code {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.doc-content pre {
|
||||
overflow-x: auto;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.doc-content pre code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.doc-content blockquote {
|
||||
border-left: 4px solid #2563eb;
|
||||
padding-left: 1rem;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.doc-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.doc-content th,
|
||||
.doc-content td {
|
||||
border: 1px solid var(--doc-line);
|
||||
padding: .625rem .75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.doc-muted {
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.doc-attachment-list {
|
||||
display: grid;
|
||||
gap: .625rem;
|
||||
}
|
||||
|
||||
.doc-attachment-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: .875rem;
|
||||
border: 1px solid var(--doc-line);
|
||||
border-radius: .875rem;
|
||||
padding: .75rem .875rem;
|
||||
background: var(--doc-panel);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.doc-hero-row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.doc-grid.stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.doc-filter,
|
||||
.doc-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.doc-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.doc-grid.stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.doc-hero {
|
||||
padding: 1.375rem;
|
||||
border-radius: 1.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Documentation reading experience */
|
||||
.doc-card-body:has(.doc-content) {
|
||||
padding: clamp(1.5rem, 3vw, 3rem);
|
||||
}
|
||||
|
||||
.doc-content {
|
||||
max-width: 68rem;
|
||||
margin: 0 auto;
|
||||
color: var(--doc-text);
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.8;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.doc-content > :first-child { margin-top: 0; }
|
||||
.doc-content > :last-child { margin-bottom: 0; }
|
||||
.doc-content h1 { font-size: clamp(2rem, 4vw, 2.75rem); margin-top: 0; margin-bottom: 1.25rem; }
|
||||
.doc-content h2 { font-size: clamp(1.45rem, 2.5vw, 1.8rem); margin-top: 2.4rem; margin-bottom: 1rem; padding-bottom: .55rem; }
|
||||
.doc-content h3 { font-size: 1.3rem; margin-top: 2rem; margin-bottom: .75rem; }
|
||||
.doc-content h4 { font-size: 1.1rem; margin: 1.75rem 0 .65rem; }
|
||||
.doc-content p { margin: 0 0 1.2rem; }
|
||||
.doc-content ul,
|
||||
.doc-content ol { margin: 0 0 1.35rem 1.5rem; padding-left: .75rem; }
|
||||
.doc-content li { margin: .4rem 0; padding-left: .25rem; }
|
||||
.doc-content li::marker { color: #60a5fa; font-weight: 700; }
|
||||
.doc-content a { color: #60a5fa; text-decoration: underline; text-decoration-color: rgba(96,165,250,.4); text-underline-offset: .18em; }
|
||||
.doc-content a:hover { color: #93c5fd; text-decoration-color: currentColor; }
|
||||
.doc-content pre { margin: 1.4rem 0 1.65rem; border: 1px solid #263244; background: #0b1220; padding: 1.15rem 1.25rem; box-shadow: inset 0 1px 0 rgba(255,255,255,.03); }
|
||||
.doc-content pre code { font-size: .925rem; line-height: 1.65; white-space: pre; }
|
||||
.doc-content :not(pre) > code { border: 1px solid rgba(148,163,184,.18); background: rgba(30,41,59,.85); color: #e2e8f0; }
|
||||
.doc-content blockquote { margin: 1.5rem 0; border-left-width: 3px; border-radius: 0 .6rem .6rem 0; background: rgba(37,99,235,.07); padding: .9rem 1.1rem; }
|
||||
.doc-content table { display: block; overflow-x: auto; margin: 1.5rem 0; border-radius: .75rem; }
|
||||
.doc-content th { background: rgba(255,255,255,.035); font-weight: 700; }
|
||||
.doc-content hr { margin: 2.25rem 0; border: 0; border-top: 1px solid var(--doc-line); }
|
||||
|
||||
/* Pagination: never use Laravel's light/white button treatment in the dark Helpdesk. */
|
||||
.doc-pagination { margin-top: 1.125rem; }
|
||||
.doc-pagination nav > div:first-child { display: none; }
|
||||
.doc-pagination nav > div:last-child { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
.doc-pagination nav > div:last-child > div:first-child { color: var(--doc-muted); }
|
||||
.doc-pagination nav span[aria-current="page"] > span,
|
||||
.doc-pagination nav a,
|
||||
.doc-pagination nav span[aria-disabled="true"] > span {
|
||||
background: #171a1f !important;
|
||||
border-color: #343a46 !important;
|
||||
color: #d6dae2 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.doc-pagination nav a:hover {
|
||||
background: #232831 !important;
|
||||
border-color: #4b5563 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.doc-pagination nav span[aria-current="page"] > span {
|
||||
background: #2563eb !important;
|
||||
border-color: #2563eb !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.doc-pagination nav span[aria-disabled="true"] > span { color: #697180 !important; cursor: not-allowed; }
|
||||
.doc-pagination nav svg { width: 1rem; height: 1rem; }
|
||||
|
||||
/* Documentation: professional contrast and strict light/dark mode parity. */
|
||||
html:not(.dark) .documentation-surface,
|
||||
html:not(.dark) [data-documentation-surface] { color:#18181b !important; }
|
||||
html:not(.dark) .documentation-surface :is(.text-zinc-400,.text-zinc-500,.text-zinc-600,.text-gray-400,.text-gray-500,.text-gray-600),
|
||||
html:not(.dark) [data-documentation-surface] :is(.text-zinc-400,.text-zinc-500,.text-zinc-600,.text-gray-400,.text-gray-500,.text-gray-600) { color:#52525b !important; }
|
||||
html.dark .documentation-surface :is(.text-zinc-400,.text-zinc-500,.text-zinc-600,.text-gray-400,.text-gray-500,.text-gray-600),
|
||||
html.dark [data-documentation-surface] :is(.text-zinc-400,.text-zinc-500,.text-zinc-600,.text-gray-400,.text-gray-500,.text-gray-600) { color:#a1a1aa !important; }
|
||||
html:not(.dark) .documentation-surface :is(section,article,.card,[class*="rounded"]):not([class*="bg-"]){ color:#27272a; }
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
document.addEventListener('click', function(event) {
|
||||
const button = event.target.closest('[data-md-before]');
|
||||
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = button.closest('[data-documentation-editor]');
|
||||
const textarea = editor ? editor.querySelector('[data-markdown-textarea]') : null;
|
||||
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const before = button.getAttribute('data-md-before') || '';
|
||||
const after = button.getAttribute('data-md-after') || '';
|
||||
const start = textarea.selectionStart || 0;
|
||||
const end = textarea.selectionEnd || 0;
|
||||
const selected = textarea.value.substring(start, end);
|
||||
const replacement = before + selected + after;
|
||||
|
||||
textarea.setRangeText(replacement, start, end, 'end');
|
||||
textarea.focus();
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
@extends('documentation::layouts.master')
|
||||
|
||||
@section('title', 'Create Category')
|
||||
|
||||
@section('content')
|
||||
<div class="doc-page">
|
||||
@include('documentation::partials.hero', [
|
||||
'title' => 'Create category',
|
||||
'subtitle' => 'Add a professional grouping for documents and operational knowledge.',
|
||||
])
|
||||
<section class="doc-card">
|
||||
<div class="doc-card-header"><strong>Category details</strong></div>
|
||||
<div class="doc-card-body">
|
||||
<form method="POST" action="{{ route('documentation.categories.store') }}">
|
||||
@include('documentation::partials.category-form')
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,20 @@
|
||||
@extends('documentation::layouts.master')
|
||||
|
||||
@section('title', 'Edit Category')
|
||||
|
||||
@section('content')
|
||||
<div class="doc-page">
|
||||
@include('documentation::partials.hero', [
|
||||
'title' => 'Edit category',
|
||||
'subtitle' => 'Update category naming, ordering, visibility, and presentation metadata.',
|
||||
])
|
||||
<section class="doc-card">
|
||||
<div class="doc-card-header"><strong>{{ $category->name }}</strong></div>
|
||||
<div class="doc-card-body">
|
||||
<form method="POST" action="{{ route('documentation.categories.update', $category) }}">
|
||||
@include('documentation::partials.category-form')
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,71 @@
|
||||
@extends('documentation::layouts.master')
|
||||
|
||||
@section('title', 'Document Categories')
|
||||
|
||||
@section('content')
|
||||
<div class="doc-page">
|
||||
@include('documentation::partials.hero', [
|
||||
'title' => 'Document categories',
|
||||
'subtitle' =>
|
||||
'Organise documents by policy, process, onboarding, operational support, or technical area.',
|
||||
])
|
||||
|
||||
@if (session('status'))
|
||||
<div class="doc-alert">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<section class="doc-card">
|
||||
<div class="doc-card-header">
|
||||
<strong>Categories</strong>
|
||||
<a class="doc-button category" href="{{ route('documentation.categories.create') }}">New category</a>
|
||||
</div>
|
||||
<div class="doc-card-body">
|
||||
<table class="doc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Documents</th>
|
||||
<th>Order</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($categories as $category)
|
||||
<tr>
|
||||
<td><strong>{{ $category->name }}</strong>
|
||||
<div class="doc-muted">{{ $category->slug }}</div>
|
||||
</td>
|
||||
<td>{{ $category->description ?: 'No description' }}</td>
|
||||
<td>{{ $category->documents_count }}</td>
|
||||
<td>{{ $category->sort_order }}</td>
|
||||
<td><span
|
||||
class="doc-badge {{ $category->is_active ? '' : 'archived' }}">{{ $category->is_active ? 'Active' : 'Inactive' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<a class="doc-link"
|
||||
href="{{ route('documentation.categories.edit', $category) }}">Edit</a>
|
||||
<form method="POST"
|
||||
action="{{ route('documentation.categories.destroy', $category) }}"
|
||||
style="display:inline;"
|
||||
onsubmit="return confirm('Delete this category? Documents will become uncategorised.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="doc-button danger"
|
||||
style="border:0;cursor:pointer;margin-left:10px;">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="doc-muted">No categories found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top:18px;">{{ $categories->links() }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,120 @@
|
||||
@extends('documentation::layouts.master')
|
||||
|
||||
@section('title', 'Archived Documents')
|
||||
|
||||
@section('content')
|
||||
<div class="doc-page">
|
||||
@include('documentation::partials.hero', [
|
||||
'title' => 'Archived documents',
|
||||
'subtitle' =>
|
||||
'Soft-deleted documents are retained here with their files and attachments until permanently deleted.',
|
||||
'actions' => [
|
||||
[
|
||||
'label' => 'Active documents',
|
||||
'url' => route('documentation.documents.index'),
|
||||
'class' => 'primary',
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
@if (session('status'))
|
||||
<div class="doc-alert">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<section class="doc-card">
|
||||
<div class="doc-card-header">
|
||||
<div>
|
||||
<strong>Archive</strong>
|
||||
<div class="doc-muted">
|
||||
Restore documents or permanently remove them and their stored files.
|
||||
</div>
|
||||
</div>
|
||||
<span class="doc-badge archived">{{ $documents->total() }} archived</span>
|
||||
</div>
|
||||
|
||||
<div class="doc-card-body">
|
||||
<form class="doc-filter" method="GET" action="{{ route('documentation.documents.archived') }}">
|
||||
<input
|
||||
class="doc-input"
|
||||
name="search"
|
||||
value="{{ request('search') }}"
|
||||
placeholder="Search archived documents"
|
||||
>
|
||||
|
||||
<select class="doc-select" name="category">
|
||||
<option value="">All categories</option>
|
||||
@foreach ($categories as $category)
|
||||
<option value="{{ $category->id }}" @selected((string) request('category') === (string) $category->id)>
|
||||
{{ $category->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
<button class="doc-button primary" type="submit">Filter</button>
|
||||
|
||||
@if (request()->filled('search') || request()->filled('category'))
|
||||
<a class="doc-button secondary" href="{{ route('documentation.documents.archived') }}">Clear</a>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
<table class="doc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Document</th>
|
||||
<th>Category</th>
|
||||
<th>Version</th>
|
||||
<th>Archived</th>
|
||||
<th>Files</th>
|
||||
<th style="text-align:right;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($documents as $document)
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ $document->title }}</strong>
|
||||
<div class="doc-muted">
|
||||
{{ $document->reference ?: 'No reference' }} ·
|
||||
{{ $document->summary ?: 'No summary' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ $document->category?->name ?: 'Uncategorised' }}</td>
|
||||
<td>{{ $document->version }}</td>
|
||||
<td>{{ $document->deleted_at?->format('d M Y H:i') }}</td>
|
||||
<td>
|
||||
{{ ($document->attachment_name ? 1 : 0) + $document->attachments->count() }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="doc-actions" style="justify-content:flex-end;">
|
||||
<form method="POST"
|
||||
action="{{ route('documentation.documents.restore', $document->id) }}">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<button class="doc-button success" type="submit">Restore</button>
|
||||
</form>
|
||||
|
||||
<form method="POST"
|
||||
action="{{ route('documentation.documents.force-destroy', $document->id) }}"
|
||||
onsubmit="return confirm('Permanently delete this document? This will also delete every stored attachment and cannot be undone.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="doc-button danger" type="submit">Delete permanently</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="doc-muted">No archived documents found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top:18px;">
|
||||
{{ $documents->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,20 @@
|
||||
@extends('documentation::layouts.master')
|
||||
|
||||
@section('title', 'Create Document')
|
||||
|
||||
@section('content')
|
||||
<div class="doc-page">
|
||||
@include('documentation::partials.hero', [
|
||||
'title' => 'Create document',
|
||||
'subtitle' => 'Add a controlled document, guide, policy, SOP, upload, or operational note.',
|
||||
])
|
||||
<section class="doc-card">
|
||||
<div class="doc-card-header"><strong>Document details</strong></div>
|
||||
<div class="doc-card-body">
|
||||
<form method="POST" action="{{ route('documentation.documents.store') }}" enctype="multipart/form-data">
|
||||
@include('documentation::partials.document-form')
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,40 @@
|
||||
@extends('documentation::layouts.master')
|
||||
|
||||
@section('title', 'Edit Document')
|
||||
|
||||
@section('content')
|
||||
<div class="doc-page">
|
||||
@include('documentation::partials.hero', [
|
||||
'title' => 'Edit document',
|
||||
'subtitle' => 'Maintain document metadata, workflow status, version and attachment details.',
|
||||
])
|
||||
<section class="doc-card">
|
||||
<div class="doc-card-header"><strong>{{ $document->title }}</strong></div>
|
||||
<div class="doc-card-body">
|
||||
<form method="POST" action="{{ route('documentation.documents.update', $document) }}"
|
||||
enctype="multipart/form-data">
|
||||
@include('documentation::partials.document-form')
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="doc-actions" style="margin-top:18px;justify-content:flex-end;">
|
||||
<form method="POST"
|
||||
action="{{ route('documentation.documents.destroy', $document) }}"
|
||||
onsubmit="return confirm('Archive this document? Its files and attachments will be retained.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="doc-button warning" type="submit">Archive document</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@foreach ($document->attachments as $attachment)
|
||||
<form id="delete-attachment-{{ $attachment->id }}" method="POST"
|
||||
action="{{ route('documentation.documents.attachments.destroy', [$document, $attachment]) }}"
|
||||
onsubmit="return confirm('Remove this attachment?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
</form>
|
||||
@endforeach
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,122 @@
|
||||
@extends('documentation::layouts.master')
|
||||
|
||||
@section('title', 'Documentation')
|
||||
|
||||
@section('content')
|
||||
<div class="doc-page">
|
||||
@include('documentation::partials.hero', [
|
||||
'title' => 'Documentation Centre',
|
||||
'subtitle' =>
|
||||
'Create, control, publish, and archive operational documents from a clean professional register.',
|
||||
])
|
||||
|
||||
@if (session('status'))
|
||||
<div class="doc-alert">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<section class="doc-grid stats">
|
||||
<article class="doc-card doc-stat"><strong>{{ $stats['total'] }}</strong><span>Total documents</span></article>
|
||||
<article class="doc-card doc-stat"><strong>{{ $stats['published'] }}</strong><span>Published</span></article>
|
||||
<article class="doc-card doc-stat"><strong>{{ $stats['drafts'] }}</strong><span>Drafts</span></article>
|
||||
<article class="doc-card doc-stat"><strong>{{ $stats['categories'] }}</strong><span>Categories</span></article>
|
||||
<article class="doc-card doc-stat"><strong>{{ $stats['archived'] }}</strong><span>Archived</span></article>
|
||||
</section>
|
||||
|
||||
@if ($featuredDocuments->isNotEmpty())
|
||||
<section class="doc-card" style="margin-bottom:18px;">
|
||||
<div class="doc-card-header"><strong>Featured documents</strong></div>
|
||||
<div class="doc-card-body doc-grid" style="grid-template-columns:repeat(auto-fit,minmax(220px,1fr));">
|
||||
@foreach ($featuredDocuments as $featured)
|
||||
<a class="doc-card doc-card-body" style="text-decoration:none;color:inherit;box-shadow:none;"
|
||||
href="{{ route('documentation.documents.show', $featured) }}">
|
||||
<span class="doc-badge {{ $featured->status }}">{{ ucfirst($featured->status) }}</span>
|
||||
<h3 style="margin:12px 0 6px;">{{ $featured->title }}</h3>
|
||||
<p class="doc-muted" style="margin:0;">{{ $featured->summary ?: 'No summary provided.' }}</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
<section class="doc-card">
|
||||
<div class="doc-card-header">
|
||||
<strong>Document register</strong>
|
||||
<div class="doc-actions">
|
||||
<a class="doc-button secondary" href="{{ route('documentation.documents.archived') }}">
|
||||
Archived documents ({{ $stats['archived'] }})
|
||||
</a>
|
||||
<a class="doc-button success" href="{{ route('documentation.documents.create') }}">New document</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-card-body">
|
||||
<form class="doc-filter" method="GET" action="{{ route('documentation.documents.index') }}">
|
||||
<input class="doc-input" name="search" value="{{ request('search') }}"
|
||||
placeholder="Search title, reference, summary or body">
|
||||
<select class="doc-select" name="status">
|
||||
<option value="">All statuses</option>
|
||||
@foreach ($statuses as $value => $label)
|
||||
<option value="{{ $value }}" @selected(request('status') === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select class="doc-select" name="category">
|
||||
<option value="">All categories</option>
|
||||
@foreach ($categories as $category)
|
||||
<option value="{{ $category->id }}" @selected((string) request('category') === (string) $category->id)>{{ $category->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button class="doc-button primary" type="submit">Filter</button>
|
||||
</form>
|
||||
|
||||
<table class="doc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Document</th>
|
||||
<th>Category</th>
|
||||
<th>Status</th>
|
||||
<th>Version</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($documents as $document)
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="{{ route('documentation.documents.show', $document) }}">{{ $document->title }}</a>
|
||||
<div class="doc-muted">{{ $document->reference ?: 'No reference' }} ·
|
||||
{{ $document->summary ?: 'No summary' }}</div>
|
||||
</td>
|
||||
<td>{{ $document->category?->name ?: 'Uncategorised' }}</td>
|
||||
<td><span
|
||||
class="doc-badge {{ $document->status }}">{{ ucfirst($document->status) }}</span>
|
||||
</td>
|
||||
<td>{{ $document->version }}</td>
|
||||
<td>{{ $document->updated_at?->format('d M Y H:i') }}</td>
|
||||
<td>
|
||||
<div class="doc-actions" style="justify-content:flex-end;">
|
||||
<a class="doc-button primary"
|
||||
href="{{ route('documentation.documents.edit', $document) }}">Edit</a>
|
||||
<form method="POST"
|
||||
action="{{ route('documentation.documents.destroy', $document) }}"
|
||||
onsubmit="return confirm('Archive this document? Its files will be retained and it can be restored later.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="doc-button warning" type="submit">Archive</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="doc-muted">No documents found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="doc-pagination">{{ $documents->links() }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,84 @@
|
||||
@extends('documentation::layouts.master')
|
||||
|
||||
@section('title', $document->title)
|
||||
|
||||
@section('content')
|
||||
<div class="doc-page">
|
||||
@include('documentation::partials.hero', [
|
||||
'title' => $document->title,
|
||||
'subtitle' =>
|
||||
$document->summary ?:
|
||||
'Controlled documentation record with versioning, attachments and Markdown rendering.',
|
||||
'actions' => [
|
||||
[
|
||||
'label' => 'Edit document',
|
||||
'url' => route('documentation.documents.edit', $document),
|
||||
'class' => 'primary',
|
||||
],
|
||||
[
|
||||
'label' => 'Back to documents',
|
||||
'url' => route('documentation.documents.index'),
|
||||
'class' => 'secondary',
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
@if (session('status'))
|
||||
<div class="doc-alert">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="doc-actions" style="margin-bottom:18px;justify-content:flex-end;">
|
||||
<form method="POST"
|
||||
action="{{ route('documentation.documents.destroy', $document) }}"
|
||||
onsubmit="return confirm('Archive this document? Its files and attachments will be retained.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="doc-button warning" type="submit">Archive document</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section class="doc-card">
|
||||
<div class="doc-card-header">
|
||||
<div>
|
||||
<strong>{{ $document->reference ?: 'Document' }}</strong>
|
||||
<div class="doc-muted">Version {{ $document->version }} @if ($document->category)
|
||||
· {{ $document->category->name }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<span class="doc-badge {{ $document->status }}">{{ ucfirst($document->status) }}</span>
|
||||
</div>
|
||||
<div class="doc-card-body">
|
||||
<article class="doc-content">{!! $document->rendered_body !!}</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if ($document->attachment_name || $document->attachments->isNotEmpty())
|
||||
<section class="doc-card" style="margin-top: 18px;">
|
||||
<div class="doc-card-header"><strong>Attachments</strong></div>
|
||||
<div class="doc-card-body">
|
||||
<div class="doc-attachment-list">
|
||||
@if ($document->attachment_name)
|
||||
<div class="doc-attachment-item">
|
||||
<div>
|
||||
<a class="doc-link"
|
||||
href="{{ route('documentation.documents.download', $document) }}">{{ $document->attachment_name }}</a>
|
||||
<span class="doc-muted">Primary file</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@foreach ($document->attachments as $attachment)
|
||||
<div class="doc-attachment-item">
|
||||
<div>
|
||||
<a class="doc-link"
|
||||
href="{{ route('documentation.documents.attachments.download', [$document, $attachment]) }}">{{ $attachment->original_name }}</a>
|
||||
<span class="doc-muted">{{ $attachment->size_label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1 @@
|
||||
@extends('documentation::documents.index')
|
||||
@@ -0,0 +1,8 @@
|
||||
<x-layouts.app>
|
||||
|
||||
|
||||
<div class="mx-auto max-w-[1800px] space-y-6" dusk="documentation-module">
|
||||
@yield('content')
|
||||
</div>
|
||||
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,61 @@
|
||||
@csrf
|
||||
@if ($category->exists)
|
||||
@method('PUT')
|
||||
@endif
|
||||
<div class="doc-form-grid">
|
||||
<div class="doc-field">
|
||||
<label class="doc-label" for="name">Name</label>
|
||||
<input class="doc-input" id="name" name="name" value="{{ old('name', $category->name) }}" required
|
||||
maxlength="150">
|
||||
@error('name')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field">
|
||||
<label class="doc-label" for="slug">Slug</label>
|
||||
<input class="doc-input" id="slug" name="slug" value="{{ old('slug', $category->slug) }}"
|
||||
maxlength="180" placeholder="Auto-generated when blank">
|
||||
@error('slug')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field">
|
||||
<label class="doc-label" for="icon">Icon</label>
|
||||
<input class="doc-input" id="icon" name="icon" value="{{ old('icon', $category->icon) }}" maxlength="80"
|
||||
placeholder="book-open, file-check, shield">
|
||||
@error('icon')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field">
|
||||
<label class="doc-label" for="colour">Colour</label>
|
||||
<input class="doc-input" id="colour" name="colour"
|
||||
value="{{ old('colour', $category->colour ?: 'emerald') }}" required maxlength="32">
|
||||
@error('colour')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field">
|
||||
<label class="doc-label" for="sort_order">Sort order</label>
|
||||
<input class="doc-input" id="sort_order" name="sort_order" type="number" min="0"
|
||||
value="{{ old('sort_order', $category->sort_order ?: 0) }}">
|
||||
@error('sort_order')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field" style="display:flex;align-items:end;">
|
||||
<label><input type="checkbox" name="is_active" value="1" @checked(old('is_active', $category->is_active ?? true))> Active
|
||||
category</label>
|
||||
</div>
|
||||
<div class="doc-field full">
|
||||
<label class="doc-label" for="description">Description</label>
|
||||
<textarea class="doc-textarea" id="description" name="description" style="min-height:140px;">{{ old('description', $category->description) }}</textarea>
|
||||
@error('description')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-actions" style="margin-top:22px;">
|
||||
<button class="doc-button success" type="submit">Save category</button>
|
||||
<a class="doc-button warning" href="{{ route('documentation.categories.index') }}">Cancel</a>
|
||||
</div>
|
||||
@@ -0,0 +1,147 @@
|
||||
@csrf
|
||||
@if ($document->exists)
|
||||
@method('PUT')
|
||||
@endif
|
||||
|
||||
<div class="doc-form-grid">
|
||||
<div class="doc-field">
|
||||
<label class="doc-label" for="title">Title</label>
|
||||
<input class="doc-input" id="title" name="title" value="{{ old('title', $document->title) }}" required
|
||||
maxlength="220">
|
||||
@error('title')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field">
|
||||
<label class="doc-label" for="slug">Slug</label>
|
||||
<input class="doc-input" id="slug" name="slug" value="{{ old('slug', $document->slug) }}"
|
||||
maxlength="240" placeholder="Auto-generated when blank">
|
||||
@error('slug')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field">
|
||||
<label class="doc-label" for="documentation_category_id">Category</label>
|
||||
<select class="doc-select" id="documentation_category_id" name="documentation_category_id">
|
||||
<option value="">No category</option>
|
||||
@foreach ($categories as $category)
|
||||
<option value="{{ $category->id }}" @selected((string) old('documentation_category_id', $document->documentation_category_id) === (string) $category->id)>{{ $category->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('documentation_category_id')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field">
|
||||
<label class="doc-label" for="status">Status</label>
|
||||
<select class="doc-select" id="status" name="status" required>
|
||||
@foreach ($statuses as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('status', $document->status) === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('status')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field">
|
||||
<label class="doc-label" for="version">Version</label>
|
||||
<input class="doc-input" id="version" name="version"
|
||||
value="{{ old('version', $document->version ?: '1.0.0') }}" required maxlength="50">
|
||||
@error('version')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field">
|
||||
<label class="doc-label" for="reference">Reference</label>
|
||||
<input class="doc-input" id="reference" name="reference" value="{{ old('reference', $document->reference) }}"
|
||||
maxlength="100" placeholder="SOP-001, POL-002, etc.">
|
||||
@error('reference')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field full">
|
||||
<label class="doc-label" for="summary">Summary</label>
|
||||
<input class="doc-input" id="summary" name="summary" value="{{ old('summary', $document->summary) }}"
|
||||
maxlength="500">
|
||||
@error('summary')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field full">
|
||||
<label class="doc-label" for="body">Document body</label>
|
||||
<div class="doc-editor" data-documentation-editor>
|
||||
<div class="doc-editor-toolbar" aria-label="Markdown editor controls">
|
||||
<button type="button" data-md-before="## " data-md-after="" title="Heading">H2</button>
|
||||
<button type="button" data-md-before="**" data-md-after="**" title="Bold"><strong>B</strong></button>
|
||||
<button type="button" data-md-before="_" data-md-after="_" title="Italic"><em>I</em></button>
|
||||
<button type="button" data-md-before="> " data-md-after="" title="Quote">Quote</button>
|
||||
<button type="button" data-md-before="- " data-md-after="" title="Bullet list">List</button>
|
||||
<button type="button" data-md-before="1. " data-md-after="" title="Numbered list">1.</button>
|
||||
<button type="button" data-md-before="[" data-md-after="](https://)" title="Link">Link</button>
|
||||
<button type="button" data-md-before="`" data-md-after="`" title="Inline code">Code</button>
|
||||
<button type="button" data-md-before="```php " data-md-after=" ```" title="Code block">Code
|
||||
block</button>
|
||||
<button type="button" data-md-before="| Column | Column | | --- | --- | | Value | Value |"
|
||||
data-md-after="" title="Table">Table</button>
|
||||
</div>
|
||||
<textarea class="doc-textarea" id="body" name="body" data-markdown-textarea>{{ old('body', $document->body) }}</textarea>
|
||||
<p class="doc-muted editor-help">Markdown is supported, including fenced code blocks such as
|
||||
<code>```php</code>, tables, lists, links, and headings.
|
||||
</p>
|
||||
</div>
|
||||
@error('body')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field full">
|
||||
<label class="doc-label" for="attachment">Primary attachment</label>
|
||||
<input class="doc-input" id="attachment" name="attachment" type="file">
|
||||
<p class="doc-muted">Primary Markdown files can be imported into the document body when the body is blank.</p>
|
||||
@if ($document->attachment_name)
|
||||
<p class="doc-muted">Current primary file: <a class="doc-link"
|
||||
href="{{ route('documentation.documents.download', $document) }}">{{ $document->attachment_name }}</a>
|
||||
</p>
|
||||
<label><input type="checkbox" name="remove_attachment" value="1"> Remove current primary
|
||||
attachment</label>
|
||||
@endif
|
||||
@error('attachment')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="doc-field full">
|
||||
<label class="doc-label" for="attachments">Additional attachments</label>
|
||||
<input class="doc-input" id="attachments" name="attachments[]" type="file" multiple>
|
||||
<p class="doc-muted">Attach supporting files such as PDFs, screenshots, spreadsheets, Word documents, Markdown,
|
||||
CSV, and images.</p>
|
||||
@error('attachments')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
@error('attachments.*')
|
||||
<div class="doc-error">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
@if ($document->exists && $document->attachments->isNotEmpty())
|
||||
<div class="doc-attachment-list" style="margin-top: 14px;">
|
||||
@foreach ($document->attachments as $attachment)
|
||||
<div class="doc-attachment-item">
|
||||
<div>
|
||||
<a class="doc-link"
|
||||
href="{{ route('documentation.documents.attachments.download', [$document, $attachment]) }}">{{ $attachment->original_name }}</a>
|
||||
<span class="doc-muted">{{ $attachment->size_label }}</span>
|
||||
</div>
|
||||
<button class="doc-button danger" type="submit"
|
||||
form="delete-attachment-{{ $attachment->id }}">Remove</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="doc-field full">
|
||||
<label><input type="checkbox" name="is_featured" value="1" @checked(old('is_featured', $document->is_featured))> Feature this
|
||||
document on the dashboard</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-actions" style="margin-top:22px;">
|
||||
<button class="doc-button success" type="submit">Save document</button>
|
||||
<a class="doc-button warning" href="{{ route('documentation.documents.index') }}">Cancel</a>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<section class="doc-hero-wrap">
|
||||
<div class="doc-hero">
|
||||
<div class="doc-hero-row">
|
||||
<div>
|
||||
<p class="doc-eyebrow">{{ $eyebrow ?? 'Documentation workflow' }}</p>
|
||||
<h1>{{ $title ?? 'Documentation' }}</h1>
|
||||
<p>{{ $subtitle ?? 'Manage controlled documents, policies, guides, files, and operational knowledge in one professional workspace.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="doc-actions">
|
||||
<a class="doc-button success" href="{{ route('documentation.documents.create') }}" wire:navigate>Create
|
||||
document</a>
|
||||
<a class="doc-button category" href="{{ route('documentation.categories.index') }}" wire:navigate>Manage
|
||||
categories</a>
|
||||
<a class="doc-button register" href="{{ route('documentation.documents.index') }}" wire:navigate>Document
|
||||
register</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['api', 'auth:sanctum'])
|
||||
->prefix('documentation')
|
||||
->name('api.documentation.')
|
||||
->group(function (): void {
|
||||
// Reserved for tenant-aware documentation API endpoints.
|
||||
});
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Documentation\Http\Controllers\DocumentationCategoryController;
|
||||
use Modules\Documentation\Http\Controllers\DocumentationController;
|
||||
|
||||
Route::middleware(['web', 'auth'])
|
||||
->prefix('documentation')
|
||||
->name('documentation.')
|
||||
->group(function (): void {
|
||||
Route::get('/', [DocumentationController::class, 'index'])->name('index');
|
||||
|
||||
Route::get('/documents', [DocumentationController::class, 'index'])->name('documents.index');
|
||||
Route::get('/documents/archived', [DocumentationController::class, 'archived'])->name('documents.archived');
|
||||
Route::patch('/documents/{document}/restore', [DocumentationController::class, 'restore'])
|
||||
->whereNumber('document')
|
||||
->name('documents.restore');
|
||||
Route::delete('/documents/{document}/force', [DocumentationController::class, 'forceDestroy'])
|
||||
->whereNumber('document')
|
||||
->name('documents.force-destroy');
|
||||
Route::get('/documents/create', [DocumentationController::class, 'create'])->name('documents.create');
|
||||
Route::post('/documents', [DocumentationController::class, 'store'])->name('documents.store');
|
||||
Route::get('/documents/{document}', [DocumentationController::class, 'show'])->name('documents.show');
|
||||
Route::get('/documents/{document}/edit', [DocumentationController::class, 'edit'])->name('documents.edit');
|
||||
Route::put('/documents/{document}', [DocumentationController::class, 'update'])->name('documents.update');
|
||||
Route::delete('/documents/{document}', [DocumentationController::class, 'destroy'])->name('documents.destroy');
|
||||
Route::get('/documents/{document}/download', [DocumentationController::class, 'download'])->name('documents.download');
|
||||
Route::get('/documents/{document}/attachments/{attachment}/download', [DocumentationController::class, 'downloadAttachment'])->name('documents.attachments.download');
|
||||
Route::delete('/documents/{document}/attachments/{attachment}', [DocumentationController::class, 'destroyAttachment'])->name('documents.attachments.destroy');
|
||||
|
||||
Route::resource('categories', DocumentationCategoryController::class)->except(['show']);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Documentation\Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Documentation\Models\DocumentationDocument;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class DocumentationArchiveTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_archiving_soft_deletes_document_but_retains_files(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$document = DocumentationDocument::query()->create([
|
||||
'title' => 'Archive me',
|
||||
'slug' => 'archive-me',
|
||||
'status' => DocumentationDocument::STATUS_PUBLISHED,
|
||||
'version' => '1.0.0',
|
||||
'attachment_disk' => 'public',
|
||||
'attachment_path' => 'documentation/primary.pdf',
|
||||
'attachment_name' => 'primary.pdf',
|
||||
]);
|
||||
|
||||
Storage::disk('public')->put('documentation/primary.pdf', 'primary');
|
||||
|
||||
$attachment = $document->attachments()->create([
|
||||
'disk' => 'public',
|
||||
'path' => 'documentation/support.pdf',
|
||||
'original_name' => 'support.pdf',
|
||||
]);
|
||||
Storage::disk('public')->put($attachment->path, 'support');
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('documentation.documents.destroy', $document))
|
||||
->assertRedirect(route('documentation.documents.index'));
|
||||
|
||||
$this->assertSoftDeleted('documentation_documents', ['id' => $document->id]);
|
||||
Storage::disk('public')->assertExists('documentation/primary.pdf');
|
||||
Storage::disk('public')->assertExists('documentation/support.pdf');
|
||||
$this->assertDatabaseHas('documentation_attachments', ['id' => $attachment->id]);
|
||||
}
|
||||
|
||||
public function test_archived_document_can_be_restored_as_draft(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$document = DocumentationDocument::query()->create([
|
||||
'title' => 'Restore me',
|
||||
'slug' => 'restore-me',
|
||||
'status' => DocumentationDocument::STATUS_ARCHIVED,
|
||||
'version' => '1.0.0',
|
||||
]);
|
||||
$document->delete();
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('documentation.documents.restore', $document->id))
|
||||
->assertRedirect(route('documentation.documents.archived'));
|
||||
|
||||
$restored = DocumentationDocument::query()->findOrFail($document->id);
|
||||
|
||||
$this->assertSame(DocumentationDocument::STATUS_DRAFT, $restored->status);
|
||||
$this->assertNull($restored->deleted_at);
|
||||
}
|
||||
|
||||
public function test_permanent_delete_removes_archived_record_and_files(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$document = DocumentationDocument::query()->create([
|
||||
'title' => 'Delete me',
|
||||
'slug' => 'delete-me',
|
||||
'status' => DocumentationDocument::STATUS_ARCHIVED,
|
||||
'version' => '1.0.0',
|
||||
'attachment_disk' => 'public',
|
||||
'attachment_path' => 'documentation/primary.pdf',
|
||||
'attachment_name' => 'primary.pdf',
|
||||
]);
|
||||
|
||||
Storage::disk('public')->put('documentation/primary.pdf', 'primary');
|
||||
|
||||
$document->attachments()->create([
|
||||
'disk' => 'public',
|
||||
'path' => 'documentation/support.pdf',
|
||||
'original_name' => 'support.pdf',
|
||||
]);
|
||||
Storage::disk('public')->put('documentation/support.pdf', 'support');
|
||||
|
||||
$document->delete();
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('documentation.documents.force-destroy', $document->id))
|
||||
->assertRedirect(route('documentation.documents.archived'));
|
||||
|
||||
$this->assertDatabaseMissing('documentation_documents', ['id' => $document->id]);
|
||||
Storage::disk('public')->assertMissing('documentation/primary.pdf');
|
||||
Storage::disk('public')->assertMissing('documentation/support.pdf');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Documentation\Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Documentation\Models\DocumentationCategory;
|
||||
use Modules\Documentation\Models\DocumentationDocument;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DocumentationModuleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Add a CSRF token for projects that keep web middleware enabled during tests.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function csrfPayload(array $payload): array
|
||||
{
|
||||
return array_merge(['_token' => 'test-token'], $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* The documentation dashboard renders for authenticated users.
|
||||
*/
|
||||
public function test_documentation_dashboard_renders(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('documentation.documents.index'))
|
||||
->assertOk()
|
||||
->assertSee('Documentation Centre');
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents can be created through the CRUD endpoint.
|
||||
*/
|
||||
public function test_document_can_be_created(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$category = DocumentationCategory::query()->create([
|
||||
'name' => 'Operations',
|
||||
'slug' => 'operations',
|
||||
'colour' => 'emerald',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([
|
||||
'documentation_category_id' => $category->id,
|
||||
'title' => 'Goods In Process',
|
||||
'slug' => 'goods-in-process',
|
||||
'summary' => 'Inbound warehouse process.',
|
||||
'body' => 'Step-by-step goods in process.',
|
||||
'status' => DocumentationDocument::STATUS_PUBLISHED,
|
||||
'version' => '1.0.0',
|
||||
'reference' => 'SOP-001',
|
||||
'is_featured' => '1',
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('documentation_documents', [
|
||||
'title' => 'Goods In Process',
|
||||
'status' => DocumentationDocument::STATUS_PUBLISHED,
|
||||
'reference' => 'SOP-001',
|
||||
'is_featured' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown attachments are stored and imported into the document body.
|
||||
*/
|
||||
public function test_markdown_attachment_is_imported_and_rendered(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$file = UploadedFile::fake()->createWithContent('goods-in.md', "# Goods In\n\n- Scan delivery note\n- Confirm quantities");
|
||||
|
||||
$response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([
|
||||
'title' => 'Goods In Markdown',
|
||||
'slug' => 'goods-in-markdown',
|
||||
'summary' => 'Markdown controlled document.',
|
||||
'body' => '',
|
||||
'status' => DocumentationDocument::STATUS_PUBLISHED,
|
||||
'version' => '1.0.0',
|
||||
'attachment' => $file,
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$document = DocumentationDocument::query()->where('slug', 'goods-in-markdown')->firstOrFail();
|
||||
|
||||
$this->assertStringContainsString('# Goods In', (string) $document->body);
|
||||
$this->assertStringContainsString('<h1>Goods In</h1>', $document->rendered_body);
|
||||
Storage::disk('public')->assertExists($document->attachment_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiple supporting attachments can be uploaded, downloaded and removed.
|
||||
*/
|
||||
public function test_document_supports_multiple_attachments(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([
|
||||
'title' => 'Attachment Pack',
|
||||
'slug' => 'attachment-pack',
|
||||
'summary' => 'Document with supporting files.',
|
||||
'body' => 'Attach evidence and supporting material.',
|
||||
'status' => DocumentationDocument::STATUS_PUBLISHED,
|
||||
'version' => '1.0.0',
|
||||
'attachments' => [
|
||||
UploadedFile::fake()->createWithContent('setup.md', "```php\necho 'ok';\n```"),
|
||||
UploadedFile::fake()->create('process.pdf', 64, 'application/pdf'),
|
||||
],
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$document = DocumentationDocument::query()->where('slug', 'attachment-pack')->firstOrFail();
|
||||
|
||||
$this->assertCount(2, $document->attachments()->get());
|
||||
|
||||
$attachment = $document->attachments()->where('original_name', 'setup.md')->firstOrFail();
|
||||
Storage::disk('public')->assertExists($attachment->path);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('documentation.documents.attachments.download', [$document, $attachment]))
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession(['_token' => 'test-token'])
|
||||
->delete(route('documentation.documents.attachments.destroy', [$document, $attachment]), ['_token' => 'test-token'])
|
||||
->assertRedirect(route('documentation.documents.edit', $document));
|
||||
|
||||
$this->assertDatabaseMissing('documentation_attachments', ['id' => $attachment->id]);
|
||||
Storage::disk('public')->assertMissing($attachment->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor renders Markdown controls for code blocks and tables.
|
||||
*/
|
||||
public function test_document_editor_has_markdown_code_controls(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('documentation.documents.create'))
|
||||
->assertOk()
|
||||
->assertSee('data-documentation-editor', false)
|
||||
->assertSee('Code block')
|
||||
->assertSee('Markdown is supported');
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories can be created through the CRUD endpoint.
|
||||
*/
|
||||
public function test_category_can_be_created(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.categories.store'), $this->csrfPayload([
|
||||
'name' => 'Policies',
|
||||
'slug' => 'policies',
|
||||
'colour' => 'emerald',
|
||||
'description' => 'Controlled business policies.',
|
||||
'sort_order' => 1,
|
||||
'is_active' => '1',
|
||||
]))->assertRedirect(route('documentation.categories.index'));
|
||||
|
||||
$this->assertDatabaseHas('documentation_categories', [
|
||||
'name' => 'Policies',
|
||||
'slug' => 'policies',
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
- version: 1.6.5
|
||||
date: '2026-06-16'
|
||||
changes:
|
||||
- Reworked Helpdesk action styling so header, support, settings, table, save, reset, edit and delete buttons use reliable coloured native styling.
|
||||
- Replaced settings section card dynamic Tailwind colour classes with inline styles so card colours always render.
|
||||
- Improved Kanban board dropdown styling.
|
||||
- Hardened ticket create inputs and Select2 controls to prevent white flashes during save.
|
||||
|
||||
1.6.1:
|
||||
date: 2026-06-15
|
||||
changes:
|
||||
- Added missing Documentation RouteServiceProvider so Composer package discovery and artisan commands can boot cleanly.
|
||||
|
||||
releases:
|
||||
- version: 1.6.1
|
||||
date: 2026-06-15
|
||||
changes:
|
||||
- Fixed missing DocumentationCategoryController binding failure for documentation category routes.
|
||||
- Added explicit DocumentationServiceProvider, route files, config, and guarded migrations so the module registers cleanly in nwidart/laravel-modules.
|
||||
- Preserved WMS shell styling and Documentation CRUD/Markdown/attachment functionality.
|
||||
|
||||
1.6.2:
|
||||
date: 2026-06-15
|
||||
changes:
|
||||
- Applied explicit coloured action buttons for Documentation create, category management, register, save, cancel, filter, edit and delete actions.
|
||||
- Preserved existing Documentation CRUD, Markdown editor and attachment behaviour.
|
||||
|
||||
releases:
|
||||
- version: 1.6.2
|
||||
date: 2026-06-15
|
||||
changes:
|
||||
- Applied explicit coloured Documentation action buttons across pages and forms.
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "mpba/documentation",
|
||||
"description": "Professional documentation and document management module.",
|
||||
"type": "laravel-module",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Gregory",
|
||||
"email": "mark.gregory@mpba.co.uk"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [],
|
||||
"aliases": {}
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Documentation\\": ""
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Modules\\Documentation\\Tests\\": "Tests/"
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Documentation",
|
||||
"alias": "documentation",
|
||||
"version": "1.6.5",
|
||||
"description": "Professional document management and knowledge-base module with WMS shell styling, categories, CRUD, publishing, Markdown editing/import support, code blocks, attachments, and Support navigation integration.",
|
||||
"keywords": [
|
||||
"documentation",
|
||||
"documents",
|
||||
"knowledge-base",
|
||||
"crud"
|
||||
],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\Documentation\\Providers\\DocumentationServiceProvider"
|
||||
],
|
||||
"aliases": {},
|
||||
"files": [],
|
||||
"requires": []
|
||||
}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+27
-2
@@ -4,9 +4,30 @@ declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'route_prefix' => env('HELPDESK_ROUTE_PREFIX', 'admin/helpdesk'),
|
||||
'middleware' => ['web', 'auth', 'thredary.admin'],
|
||||
'middleware' => ['web', 'auth'],
|
||||
'legacy_connection' => env('HELPDESK_LEGACY_DB_CONNECTION', config('database.default')),
|
||||
'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'),
|
||||
'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240),
|
||||
|
||||
'sync' => [
|
||||
// URL of the other Helpdesk host, e.g. https://helpdesk-remote.example.com
|
||||
'remote_url' => env('HELPDESK_SYNC_REMOTE_URL'),
|
||||
|
||||
// Bearer token this host sends to the configured remote host.
|
||||
'remote_token' => env('HELPDESK_SYNC_REMOTE_TOKEN'),
|
||||
|
||||
// Bearer token this host accepts on /api/helpdesk/sync/*.
|
||||
// Leave blank to disable inbound remote sync completely.
|
||||
'accept_token' => env('HELPDESK_SYNC_ACCEPT_TOKEN'),
|
||||
|
||||
'include_attachments' => env('HELPDESK_SYNC_INCLUDE_ATTACHMENTS', true),
|
||||
'max_attachment_bytes' => (int) env('HELPDESK_SYNC_MAX_ATTACHMENT_BYTES', 10485760),
|
||||
'connect_timeout' => (int) env('HELPDESK_SYNC_CONNECT_TIMEOUT', 10),
|
||||
'timeout' => (int) env('HELPDESK_SYNC_TIMEOUT', 120),
|
||||
'retries' => (int) env('HELPDESK_SYNC_RETRIES', 2),
|
||||
'retry_delay_ms' => (int) env('HELPDESK_SYNC_RETRY_DELAY_MS', 500),
|
||||
],
|
||||
|
||||
'allowed_extensions' => [
|
||||
'md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'log', 'zip',
|
||||
'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
|
||||
@@ -24,7 +45,11 @@ return [
|
||||
'blocked_extensions' => ['exe', 'dll', 'com', 'scr', 'msi', 'app', 'dmg', 'pkg', 'jar', 'war'],
|
||||
'virus_scanner' => env('HELPDESK_VIRUS_SCANNER', null),
|
||||
|
||||
// Backwards-compatible alias for older module code/tests that still read this key.
|
||||
'legacy' => [
|
||||
'enabled' => (bool) env('HELPDESK_LEGACY_ENABLED', false),
|
||||
'connection' => env('HELPDESK_LEGACY_CONNECTION', 'helpdesk_legacy'),
|
||||
],
|
||||
|
||||
'allowed_mimes' => [],
|
||||
'default_status_slug' => 'new',
|
||||
'closed_status_slug' => 'closed',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user