Author SHA1 Message Date
Mark Gregory afa2ee472e added support for legacy tickets, fixed switching of modes and also style updates 2026-09-28 15:40:20 +01:00
Mark Gregory 912c120299 added support for legacy tickets, fixed switching of modes and also style updates 2026-09-28 15:37:13 +01:00
Mark Gregory cb98e67e04 added support for legacy tickets, fixed switching of modes and also style updates 2026-09-28 15:33:31 +01:00
Mark Gregory 01f5fa8e75 added support for legacy tickets, fixed switching of modes and also style updates 2026-09-28 15:31:09 +01:00
Mark Gregory 0fb4223bfc added support for legacy tickets, fixed switching of modes and also style updates 2026-09-28 15:21:32 +01:00
Mark Gregory fd6646c1ee added support for legacy tickets, fixed switching of modes 2026-09-28 14:36:45 +01:00
Mark Gregory b018a6e290 added support for legacy tickets 2026-09-28 14:34:32 +01:00
Mark Gregory 6391e38dbf added support for legacy tickets 2026-09-28 14:32:00 +01:00
Mark Gregory 02ee378ec9 added support for legacy tickets 2026-09-28 12:43:10 +01:00
Mark Gregory 1eb7c56729 added support for legacy tickets 2026-09-28 12:40:49 +01:00
Mark Gregory ddda4c9f1d added support for legacy tickets 2026-09-28 12:32:08 +01:00
Mark Gregory cd6dfdbe30 added support for legacy tickets 2026-09-28 12:29:15 +01:00
Mark Gregory 828e2c4236 added support for legacy tickets 2026-09-28 11:57:18 +01:00
Mark Gregory 77d2a5e998 added support for legacy tickets 2026-09-28 11:56:10 +01:00
Mark Gregory c4a7061e87 added support for legacy tickets 2026-09-28 11:47:27 +01:00
Mark Gregory 83d617a0a7 added support for legacy tickets 2026-09-28 11:26:27 +01:00
Mark Gregory 0a689bb11f added support for legacy tickets 2026-09-28 10:48:07 +01:00
Mark Gregory 5c51cc4c73 added support for legacy tickets 2026-09-28 10:47:18 +01:00
Mark Gregory 36a4353269 re-add theme as ptrs broken fixed ipad theme 2026-09-28 09:51:57 +01:00
Mark Gregory 465f6fe5ee re-add theme as ptrs broken fixed ipad theme 2026-09-28 09:46:52 +01:00
Mark Gregory aa23914353 re-add theme as ptrs broken fixed ipad theme 2026-09-08 09:34:10 +01:00
Mark Gregory 81e64a1c9a re-add theme as ptrs broken fixed ipad theme 2026-09-07 18:08:46 +01:00
Mark Gregory d0f72ac8a4 re-add theme as ptrs broken fixed ipad theme 2026-09-07 18:06:45 +01:00
Mark Gregory c6b7510f05 re-add theme as ptrs broken fixed ipad theme 2026-09-07 17:56:58 +01:00
Mark Gregory ab85c4a575 re-add theme as ptrs broken 2026-09-07 17:09:53 +01:00
Mark Gregory c5a14cb381 re-add theme as ptrs broken 2026-09-07 16:13:02 +01:00
Mark Gregory 57a0578ec7 performance updates 2026-09-03 10:40:39 +01:00
Mark Gregory e988a39a12 performance updates 2026-09-03 10:37:25 +01:00
Mark Gregory 2a8e52a330 performance updates 2026-09-03 10:34:49 +01:00
Mark Gregory 80eeec44b8 performance updates 2026-09-03 10:10:28 +01:00
Mark Gregory f6be7c7dea performance updates 2026-09-03 09:57:18 +01:00
Mark Gregory df7561737c performance updates 2026-09-03 09:54:07 +01:00
Mark Gregory 525607db99 performance updates 2026-09-03 09:53:01 +01:00
Mark Gregory 98ce6037de node update 2026-09-03 08:51:44 +00:00
Mark Gregory be84b1a791 performance updates 2026-09-03 09:45:39 +01:00
Mark Gregory aa604d701e dir update 2026-09-02 17:23:52 +00:00
Mark Gregory 5fa45e992b Moved 2026-09-02 17:08:57 +00:00
Mark Gregory c8408658c9 dark mode supoport and new design now with delet esupport 2026-08-27 17:28:02 +01:00
Mark Gregory 6de15d1398 dark mode supoport and new design 2026-08-27 16:01:39 +01:00
Mark Gregory a3ef18384b dark mode supoport 2026-08-26 17:12:53 +01:00
Mark Gregory fd06211c72 dark mode supoport 2026-08-26 17:02:07 +01:00
Mark Gregory 87edfc1826 structure updates 2026-08-26 15:58:32 +01:00
Mark Gregory 55458f3109 structure updates 2026-08-26 15:50:31 +01:00
Mark Gregory 7546698a3c structure updates 2026-08-26 15:34:45 +01:00
Mark Gregory 138a028537 structure updates 2026-08-26 12:38:12 +01:00
Mark Gregory 66fd8b0e8a structure updates 2026-08-26 11:30:36 +01:00
Mark Gregory 8404c6e40f structure updates 2026-08-26 11:09:27 +01:00
Mark Gregory 7fcef01be4 updaetd to roles and permissions 2026-08-26 10:38:38 +01:00
Mark Gregory af6ebe5730 updaetd to roles and permissions 2026-08-26 10:19:58 +01:00
Mark Gregory 21d7b6eba9 improvements 2026-08-26 09:25:29 +01:00
Mark Gregory 53a11ae0c7 improvements 2026-08-26 09:04:25 +01:00
Mark Gregory 5844aeb621 users menu listing 2026-08-26 08:56:45 +01:00
Mark Gregory 381d58003d users menu listing 2026-08-26 08:20:59 +01:00
Mark Gregory 31ee06fa3a users menu listing 2026-08-26 08:15:05 +01:00
Mark Gregory cd2b593fcf users menu listing 2026-08-26 08:03:36 +01:00
Mark Gregory 7dc31371f6 users menu listing 2026-08-26 08:01:49 +01:00
Mark Gregory 6f8d0c7abf settings updates, fixed 2026-08-25 23:04:18 +01:00
Mark Gregory 3c657127ac settings updates, fixed 2026-08-25 22:55:15 +01:00
Mark Gregory a49b22cf83 settings updates, fixed 2026-08-25 22:49:46 +01:00
Mark Gregory db5efbc834 settings updates, fixed 2026-08-25 22:45:59 +01:00
Mark Gregory 36cc32674e settings updates, fixed 2026-08-25 22:42:56 +01:00
Mark Gregory 34569840ae settings updates, fixed 2026-08-25 22:36:39 +01:00
Mark Gregory d359ac29b6 settings updates, fixed 2026-08-25 22:30:59 +01:00
Mark Gregory 1cad54ce42 settings updates 2026-08-25 22:19:38 +01:00
Mark Gregory 037c08498b settings updates 2026-08-25 22:19:04 +01:00
Mark Gregory 1991f27ee8 settings updates 2026-08-25 21:57:41 +01:00
Mark Gregory 9048dca8b4 updated design 2026-08-25 21:21:32 +01:00
Mark Gregory 0ee747f945 updated design 2026-08-25 21:00:20 +01:00
Mark Gregory f1399517ed updated design 2026-08-25 20:24:03 +01:00
Mark Gregory 27e6071992 updated design 2026-08-25 17:12:22 +01:00
Mark Gregory 3c7361023c updated design 2026-08-25 14:41:15 +01:00
Mark Gregory 3e6b3bfc79 updaetd deployer 2026-08-25 14:05:49 +01:00
Mark Gregory 6a16aaca3a updaetd deployer 2026-08-25 13:49:10 +01:00
Mark Gregory c4387cc03a updaetd deployer 2026-08-25 13:47:23 +01:00
Mark Gregory 6d5d257db1 updaetd deployer 2026-08-25 13:38:42 +01:00
Mark Gregory da60f76fe9 updaetd deployer 2026-08-25 13:30:45 +01:00
Mark Gregory 80f70fe8c8 updaetd deployer 2026-08-25 13:29:11 +01:00
Mark Gregory a413111fc0 updaetd deployer 2026-08-25 13:27:35 +01:00
Mark Gregory 250a6c7643 updaetd deployer 2026-08-25 13:22:48 +01:00
Mark Gregory 9dcd54b523 updaetd deployer 2026-08-25 12:41:55 +01:00
458 changed files with 22907 additions and 537 deletions
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+2
View File
@@ -26,3 +26,5 @@ yarn-error.log
*/.DS_Store
**/.DS_Store
/provision/
/.scannerwork/
/storage/logs
+81
View File
@@ -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
+151
View File
@@ -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}` |
+104
View File
@@ -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.
+141
View File
@@ -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.
+59
View File
@@ -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)
);
}
```
+202
View File
@@ -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);
}
}
```
+36
View File
@@ -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.
+70
View File
@@ -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']],
```
+44
View File
@@ -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 {}
```
+73
View File
@@ -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
```
+150
View File
@@ -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.
+160
View File
@@ -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(),
]);
```
+27
View File
@@ -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.
+121
View File
@@ -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']);
```
+144
View File
@@ -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,
],
],
],
```
+99
View File
@@ -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');
}
```
+39
View File
@@ -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');
});
```
+198
View File
@@ -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',
];
}
}
```
+125
View File
@@ -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())
```
+43
View File
@@ -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();
```
+75
View File
@@ -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.');
}
},
];
}
```
+175
View File
@@ -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
+166
View File
@@ -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`
+119
View File
@@ -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
Regular → Executable
View File
View File
-6
View File
@@ -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
+33
View File
@@ -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;
}
}
@@ -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'],
];
}
}
+6
View File
@@ -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
View File
@@ -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.
Binary file not shown.
+141
View File
@@ -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
View File
@@ -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.
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+98
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,4 @@
### The Spares Module
This details the operation and key highlights and developer notes for the Maximo ( Spares) module
+8
View File
@@ -0,0 +1,8 @@
### SSHD Server
to restart the SSH Server on AS400 use in 5250 Console
```
STRTCPSVR *SSHD
```
+461
View File
@@ -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.
---
+69
View File
@@ -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
View File
@@ -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
View File
@@ -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');
}
}
+67
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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&#10;" data-md-after="&#10;```" title="Code block">Code
block</button>
<button type="button" data-md-before="| Column | Column |&#10;| --- | --- |&#10;| 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>
+12
View File
@@ -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.
});
+34
View File
@@ -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,
]);
}
}
+32
View File
@@ -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.
+27
View File
@@ -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/"
}
}
}
+19
View File
@@ -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": []
}
+4
View File
@@ -0,0 +1,4 @@
{
"private": true,
"type": "module"
}
Regular → Executable
View File
View File
Regular → Executable
View File
+27 -2
View File
@@ -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',
View File

Some files were not shown because too many files have changed in this diff Show More