This commit is contained in:
subhajit 2026-06-12 18:22:10 +05:30
parent 688b0307b8
commit cb86f8e362
94 changed files with 1156 additions and 670 deletions

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

View File

@ -148,4 +148,4 @@ ## Key Endpoints
| Passkey Confirm | POST | `/passkeys/confirm` | | Passkey Confirm | POST | `/passkeys/confirm` |
| Passkey Options | GET | `/user/passkeys/options` | | Passkey Options | GET | `/user/passkeys/options` |
| Register Passkey | POST | `/user/passkeys` | | Register Passkey | POST | `/user/passkeys` |
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` | | Delete Passkey | DELETE | `/user/passkeys/{passkey}` |

View File

@ -187,4 +187,4 @@ ## How to Apply
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) 1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
2. Check sibling files for existing patterns — follow those first per Consistency First 2. Check sibling files for existing patterns — follow those first per Consistency First
3. Verify API syntax with `search-docs` for the installed Laravel version 3. Verify API syntax with `search-docs` for the installed Laravel version

View File

@ -103,4 +103,4 @@ ## Use Correlated Subqueries for Has-Many Ordering
->take(1) ->take(1)
); );
} }
``` ```

View File

@ -199,4 +199,4 @@ ## Convention Over Configuration
return $this->belongsToMany(Role::class); return $this->belongsToMany(Role::class);
} }
} }
``` ```

View File

@ -33,4 +33,4 @@ ## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
## Use `@aware` for Deeply Nested Component Props ## Use `@aware` for Deeply Nested Component Props
Avoids re-passing parent props through every level of nested components. Avoids re-passing parent props through every level of nested components.

View File

@ -67,4 +67,4 @@ ## Configure Failover Cache Stores in Production
```php ```php
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], 'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
``` ```

View File

@ -41,4 +41,4 @@ ## Use `#[CollectedBy]` for Custom Collection Classes
```php ```php
#[CollectedBy(UserCollection::class)] #[CollectedBy(UserCollection::class)]
class User extends Model {} class User extends Model {}
``` ```

View File

@ -70,4 +70,4 @@ ## Use Constants and Language Files
```php ```php
// Only when lang files already exist in the project // Only when lang files already exist in the project
return back()->with('message', __('app.article_added')); return back()->with('message', __('app.article_added'));
``` ```

View File

@ -189,4 +189,4 @@ ## No Queries in Blade Templates
@foreach ($users as $user) @foreach ($users as $user)
{{ $user->profile->name }} {{ $user->profile->name }}
@endforeach @endforeach
``` ```

View File

@ -145,4 +145,4 @@ ## Avoid Hardcoded Table Names in Queries
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. 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. **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.

View File

@ -69,4 +69,4 @@ ## Add Context to Exception Classes
return ['order_id' => $this->orderId]; return ['order_id' => $this->orderId];
} }
} }
``` ```

View File

@ -49,4 +49,4 @@ ## Use On-Demand Notifications for Non-User Recipients
## Implement `HasLocalePreference` on Notifiable Models ## Implement `HasLocalePreference` on Notifiable Models
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.

View File

@ -157,4 +157,4 @@ ## Fake HTTP Calls in Tests
Http::fake([ Http::fake([
'api.example.com/*' => Http::failedConnection(), 'api.example.com/*' => Http::failedConnection(),
]); ]);
``` ```

View File

@ -24,4 +24,4 @@ ## Separate Content Tests from Sending Tests
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
Don't mix them — it conflates concerns and makes tests brittle. Don't mix them — it conflates concerns and makes tests brittle.

View File

@ -118,4 +118,4 @@ ## Keep Migrations Focused
// Migration 2: seed_default_settings // Migration 2: seed_default_settings
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
``` ```

View File

@ -141,4 +141,4 @@ ## Use Horizon for Complex Queue Scenarios
], ],
], ],
], ],
``` ```

View File

@ -96,4 +96,4 @@ ## Type-Hint Form Requests
return redirect()->route('posts.index'); return redirect()->route('posts.index');
} }
``` ```

View File

@ -36,4 +36,4 @@ ## Use Schedule Groups for Shared Configuration
Schedule::command('emails:send --force'); Schedule::command('emails:send --force');
Schedule::command('emails:prune'); Schedule::command('emails:prune');
}); });
``` ```

View File

@ -195,4 +195,4 @@ ## Encrypt Sensitive Database Fields
]; ];
} }
} }
``` ```

View File

@ -122,4 +122,4 @@ ## No Unnecessary Comments
Correct: Correct:
```php ```php
if ($this->hasJoins()) if ($this->hasJoins())
``` ```

View File

@ -40,4 +40,4 @@ ## Use `recycle()` to Share Relationship Instances Across Factories
Ticket::factory() Ticket::factory()
->recycle(Airline::factory()->create()) ->recycle(Airline::factory()->create())
->create(); ->create();
``` ```

View File

@ -72,4 +72,4 @@ ## Use the `after()` Method for Custom Validation
}, },
]; ];
} }
``` ```

View File

@ -172,4 +172,4 @@ ## Common Pitfalls
- Expecting `wire:model` real-time → use `wire:model.live` - Expecting `wire:model` real-time → use `wire:model.live`
- Unclosed component tags → syntax errors in v4 - Unclosed component tags → syntax errors in v4
- Using deprecated config keys or JS hooks - Using deprecated config keys or JS hooks
- Including Alpine.js separately (already bundled in Livewire 4) - Including Alpine.js separately (already bundled in Livewire 4)

View File

@ -36,4 +36,4 @@ ### Component-Scoped Interceptors
## Magic Properties ## Magic Properties
- `$errors` - Access validation errors from JavaScript - `$errors` - Access validation errors from JavaScript
- `$intercept` - Component-scoped interceptors - `$intercept` - Component-scoped interceptors

View File

@ -163,4 +163,4 @@ ## Common Pitfalls
- Forgetting datasets for repetitive validation tests - Forgetting datasets for repetitive validation tests
- Deleting tests without approval - Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests - Forgetting `assertNoJavaScriptErrors()` in browser tests
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` - Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`

View File

@ -116,4 +116,4 @@ ## Common Pitfalls
- Using `@tailwind` directives instead of `@import "tailwindcss"` - Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive - Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities - Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode - Forgetting to add dark mode variants when the project uses dark mode

View File

@ -27,7 +27,7 @@ ## Basic Usage
## Available Components (Free Edition) ## Available Components (Free Edition)
Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip 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 ## Icons
@ -78,4 +78,4 @@ ## Common Pitfalls
- Trying to use Pro-only components in the free edition - Trying to use Pro-only components in the free edition
- Not checking if a Flux component exists before creating custom implementations - Not checking if a Flux component exists before creating custom implementations
- Forgetting to use the `search-docs` tool for component-specific documentation - Forgetting to use the `search-docs` tool for component-specific documentation
- Not following existing project patterns for Flux usage - Not following existing project patterns for Flux usage

View File

@ -148,4 +148,4 @@ ## Key Endpoints
| Passkey Confirm | POST | `/passkeys/confirm` | | Passkey Confirm | POST | `/passkeys/confirm` |
| Passkey Options | GET | `/user/passkeys/options` | | Passkey Options | GET | `/user/passkeys/options` |
| Register Passkey | POST | `/user/passkeys` | | Register Passkey | POST | `/user/passkeys` |
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` | | Delete Passkey | DELETE | `/user/passkeys/{passkey}` |

View File

@ -187,4 +187,4 @@ ## How to Apply
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) 1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
2. Check sibling files for existing patterns — follow those first per Consistency First 2. Check sibling files for existing patterns — follow those first per Consistency First
3. Verify API syntax with `search-docs` for the installed Laravel version 3. Verify API syntax with `search-docs` for the installed Laravel version

View File

@ -103,4 +103,4 @@ ## Use Correlated Subqueries for Has-Many Ordering
->take(1) ->take(1)
); );
} }
``` ```

View File

@ -199,4 +199,4 @@ ## Convention Over Configuration
return $this->belongsToMany(Role::class); return $this->belongsToMany(Role::class);
} }
} }
``` ```

View File

@ -33,4 +33,4 @@ ## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
## Use `@aware` for Deeply Nested Component Props ## Use `@aware` for Deeply Nested Component Props
Avoids re-passing parent props through every level of nested components. Avoids re-passing parent props through every level of nested components.

View File

@ -67,4 +67,4 @@ ## Configure Failover Cache Stores in Production
```php ```php
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], 'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
``` ```

View File

@ -41,4 +41,4 @@ ## Use `#[CollectedBy]` for Custom Collection Classes
```php ```php
#[CollectedBy(UserCollection::class)] #[CollectedBy(UserCollection::class)]
class User extends Model {} class User extends Model {}
``` ```

View File

@ -70,4 +70,4 @@ ## Use Constants and Language Files
```php ```php
// Only when lang files already exist in the project // Only when lang files already exist in the project
return back()->with('message', __('app.article_added')); return back()->with('message', __('app.article_added'));
``` ```

View File

@ -189,4 +189,4 @@ ## No Queries in Blade Templates
@foreach ($users as $user) @foreach ($users as $user)
{{ $user->profile->name }} {{ $user->profile->name }}
@endforeach @endforeach
``` ```

View File

@ -145,4 +145,4 @@ ## Avoid Hardcoded Table Names in Queries
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. 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. **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.

View File

@ -69,4 +69,4 @@ ## Add Context to Exception Classes
return ['order_id' => $this->orderId]; return ['order_id' => $this->orderId];
} }
} }
``` ```

View File

@ -49,4 +49,4 @@ ## Use On-Demand Notifications for Non-User Recipients
## Implement `HasLocalePreference` on Notifiable Models ## Implement `HasLocalePreference` on Notifiable Models
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.

View File

@ -157,4 +157,4 @@ ## Fake HTTP Calls in Tests
Http::fake([ Http::fake([
'api.example.com/*' => Http::failedConnection(), 'api.example.com/*' => Http::failedConnection(),
]); ]);
``` ```

View File

@ -24,4 +24,4 @@ ## Separate Content Tests from Sending Tests
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
Don't mix them — it conflates concerns and makes tests brittle. Don't mix them — it conflates concerns and makes tests brittle.

View File

@ -118,4 +118,4 @@ ## Keep Migrations Focused
// Migration 2: seed_default_settings // Migration 2: seed_default_settings
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
``` ```

View File

@ -141,4 +141,4 @@ ## Use Horizon for Complex Queue Scenarios
], ],
], ],
], ],
``` ```

View File

@ -96,4 +96,4 @@ ## Type-Hint Form Requests
return redirect()->route('posts.index'); return redirect()->route('posts.index');
} }
``` ```

View File

@ -36,4 +36,4 @@ ## Use Schedule Groups for Shared Configuration
Schedule::command('emails:send --force'); Schedule::command('emails:send --force');
Schedule::command('emails:prune'); Schedule::command('emails:prune');
}); });
``` ```

View File

@ -195,4 +195,4 @@ ## Encrypt Sensitive Database Fields
]; ];
} }
} }
``` ```

View File

@ -122,4 +122,4 @@ ## No Unnecessary Comments
Correct: Correct:
```php ```php
if ($this->hasJoins()) if ($this->hasJoins())
``` ```

View File

@ -40,4 +40,4 @@ ## Use `recycle()` to Share Relationship Instances Across Factories
Ticket::factory() Ticket::factory()
->recycle(Airline::factory()->create()) ->recycle(Airline::factory()->create())
->create(); ->create();
``` ```

View File

@ -72,4 +72,4 @@ ## Use the `after()` Method for Custom Validation
}, },
]; ];
} }
``` ```

View File

@ -172,4 +172,4 @@ ## Common Pitfalls
- Expecting `wire:model` real-time → use `wire:model.live` - Expecting `wire:model` real-time → use `wire:model.live`
- Unclosed component tags → syntax errors in v4 - Unclosed component tags → syntax errors in v4
- Using deprecated config keys or JS hooks - Using deprecated config keys or JS hooks
- Including Alpine.js separately (already bundled in Livewire 4) - Including Alpine.js separately (already bundled in Livewire 4)

View File

@ -36,4 +36,4 @@ ### Component-Scoped Interceptors
## Magic Properties ## Magic Properties
- `$errors` - Access validation errors from JavaScript - `$errors` - Access validation errors from JavaScript
- `$intercept` - Component-scoped interceptors - `$intercept` - Component-scoped interceptors

View File

@ -163,4 +163,4 @@ ## Common Pitfalls
- Forgetting datasets for repetitive validation tests - Forgetting datasets for repetitive validation tests
- Deleting tests without approval - Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests - Forgetting `assertNoJavaScriptErrors()` in browser tests
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` - Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`

View File

@ -116,4 +116,4 @@ ## Common Pitfalls
- Using `@tailwind` directives instead of `@import "tailwindcss"` - Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive - Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities - Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode - Forgetting to add dark mode variants when the project uses dark mode

View File

@ -27,7 +27,7 @@ ## Basic Usage
## Available Components (Free Edition) ## Available Components (Free Edition)
Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip 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 ## Icons
@ -78,4 +78,4 @@ ## Common Pitfalls
- Trying to use Pro-only components in the free edition - Trying to use Pro-only components in the free edition
- Not checking if a Flux component exists before creating custom implementations - Not checking if a Flux component exists before creating custom implementations
- Forgetting to use the `search-docs` tool for component-specific documentation - Forgetting to use the `search-docs` tool for component-specific documentation
- Not following existing project patterns for Flux usage - Not following existing project patterns for Flux usage

View File

@ -148,4 +148,4 @@ ## Key Endpoints
| Passkey Confirm | POST | `/passkeys/confirm` | | Passkey Confirm | POST | `/passkeys/confirm` |
| Passkey Options | GET | `/user/passkeys/options` | | Passkey Options | GET | `/user/passkeys/options` |
| Register Passkey | POST | `/user/passkeys` | | Register Passkey | POST | `/user/passkeys` |
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` | | Delete Passkey | DELETE | `/user/passkeys/{passkey}` |

View File

@ -187,4 +187,4 @@ ## How to Apply
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) 1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
2. Check sibling files for existing patterns — follow those first per Consistency First 2. Check sibling files for existing patterns — follow those first per Consistency First
3. Verify API syntax with `search-docs` for the installed Laravel version 3. Verify API syntax with `search-docs` for the installed Laravel version

View File

@ -103,4 +103,4 @@ ## Use Correlated Subqueries for Has-Many Ordering
->take(1) ->take(1)
); );
} }
``` ```

View File

@ -199,4 +199,4 @@ ## Convention Over Configuration
return $this->belongsToMany(Role::class); return $this->belongsToMany(Role::class);
} }
} }
``` ```

View File

@ -33,4 +33,4 @@ ## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
## Use `@aware` for Deeply Nested Component Props ## Use `@aware` for Deeply Nested Component Props
Avoids re-passing parent props through every level of nested components. Avoids re-passing parent props through every level of nested components.

View File

@ -67,4 +67,4 @@ ## Configure Failover Cache Stores in Production
```php ```php
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], 'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
``` ```

View File

@ -41,4 +41,4 @@ ## Use `#[CollectedBy]` for Custom Collection Classes
```php ```php
#[CollectedBy(UserCollection::class)] #[CollectedBy(UserCollection::class)]
class User extends Model {} class User extends Model {}
``` ```

View File

@ -70,4 +70,4 @@ ## Use Constants and Language Files
```php ```php
// Only when lang files already exist in the project // Only when lang files already exist in the project
return back()->with('message', __('app.article_added')); return back()->with('message', __('app.article_added'));
``` ```

View File

@ -189,4 +189,4 @@ ## No Queries in Blade Templates
@foreach ($users as $user) @foreach ($users as $user)
{{ $user->profile->name }} {{ $user->profile->name }}
@endforeach @endforeach
``` ```

View File

@ -145,4 +145,4 @@ ## Avoid Hardcoded Table Names in Queries
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. 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. **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.

View File

@ -69,4 +69,4 @@ ## Add Context to Exception Classes
return ['order_id' => $this->orderId]; return ['order_id' => $this->orderId];
} }
} }
``` ```

View File

@ -49,4 +49,4 @@ ## Use On-Demand Notifications for Non-User Recipients
## Implement `HasLocalePreference` on Notifiable Models ## Implement `HasLocalePreference` on Notifiable Models
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.

View File

@ -157,4 +157,4 @@ ## Fake HTTP Calls in Tests
Http::fake([ Http::fake([
'api.example.com/*' => Http::failedConnection(), 'api.example.com/*' => Http::failedConnection(),
]); ]);
``` ```

View File

@ -24,4 +24,4 @@ ## Separate Content Tests from Sending Tests
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
Don't mix them — it conflates concerns and makes tests brittle. Don't mix them — it conflates concerns and makes tests brittle.

View File

@ -118,4 +118,4 @@ ## Keep Migrations Focused
// Migration 2: seed_default_settings // Migration 2: seed_default_settings
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
``` ```

View File

@ -141,4 +141,4 @@ ## Use Horizon for Complex Queue Scenarios
], ],
], ],
], ],
``` ```

View File

@ -96,4 +96,4 @@ ## Type-Hint Form Requests
return redirect()->route('posts.index'); return redirect()->route('posts.index');
} }
``` ```

View File

@ -36,4 +36,4 @@ ## Use Schedule Groups for Shared Configuration
Schedule::command('emails:send --force'); Schedule::command('emails:send --force');
Schedule::command('emails:prune'); Schedule::command('emails:prune');
}); });
``` ```

View File

@ -195,4 +195,4 @@ ## Encrypt Sensitive Database Fields
]; ];
} }
} }
``` ```

View File

@ -122,4 +122,4 @@ ## No Unnecessary Comments
Correct: Correct:
```php ```php
if ($this->hasJoins()) if ($this->hasJoins())
``` ```

View File

@ -40,4 +40,4 @@ ## Use `recycle()` to Share Relationship Instances Across Factories
Ticket::factory() Ticket::factory()
->recycle(Airline::factory()->create()) ->recycle(Airline::factory()->create())
->create(); ->create();
``` ```

View File

@ -72,4 +72,4 @@ ## Use the `after()` Method for Custom Validation
}, },
]; ];
} }
``` ```

View File

@ -172,4 +172,4 @@ ## Common Pitfalls
- Expecting `wire:model` real-time → use `wire:model.live` - Expecting `wire:model` real-time → use `wire:model.live`
- Unclosed component tags → syntax errors in v4 - Unclosed component tags → syntax errors in v4
- Using deprecated config keys or JS hooks - Using deprecated config keys or JS hooks
- Including Alpine.js separately (already bundled in Livewire 4) - Including Alpine.js separately (already bundled in Livewire 4)

View File

@ -36,4 +36,4 @@ ### Component-Scoped Interceptors
## Magic Properties ## Magic Properties
- `$errors` - Access validation errors from JavaScript - `$errors` - Access validation errors from JavaScript
- `$intercept` - Component-scoped interceptors - `$intercept` - Component-scoped interceptors

View File

@ -163,4 +163,4 @@ ## Common Pitfalls
- Forgetting datasets for repetitive validation tests - Forgetting datasets for repetitive validation tests
- Deleting tests without approval - Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests - Forgetting `assertNoJavaScriptErrors()` in browser tests
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` - Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`

View File

@ -116,4 +116,4 @@ ## Common Pitfalls
- Using `@tailwind` directives instead of `@import "tailwindcss"` - Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive - Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities - Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode - Forgetting to add dark mode variants when the project uses dark mode

View File

@ -9,7 +9,7 @@ ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.5 - php - 8.4
- laravel/fortify (FORTIFY) - v1 - laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v13 - laravel/framework (LARAVEL) - v13
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
@ -87,7 +87,6 @@ ## Artisan
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. - Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. - Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. - Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
- To check environment variables, read the `.env` file directly.
## Tinker ## Tinker

View File

@ -9,7 +9,7 @@ ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.5 - php - 8.4
- laravel/fortify (FORTIFY) - v1 - laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v13 - laravel/framework (LARAVEL) - v13
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
@ -87,7 +87,6 @@ ## Artisan
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. - Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. - Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. - Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
- To check environment variables, read the `.env` file directly.
## Tinker ## Tinker

View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\KekaOutlookService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class KekaOutlookController extends Controller
{
public function __construct(private KekaOutlookService $kekaOutlook) {}
/**
* GET /keka/connect
*
* If the user already has a stored refresh token, we don't show
* Microsoft's login screen at all just confirm/refresh silently
* and send them straight back to Keka.
*
* If not connected yet, redirect to Microsoft login (one-time).
*/
public function connect(Request $request): RedirectResponse
{
$user = $request->user();
if ($this->kekaOutlook->isConnected($user)) {
try {
// Ensures token is fresh; throws if refresh token is invalid/revoked.
$this->kekaOutlook->getValidToken($user);
return redirect()->away($this->kekaWebsiteUrl());
} catch (\RuntimeException $e) {
// Refresh failed -> fall through to re-login below.
}
}
return redirect()->away($this->kekaOutlook->getAuthUrl());
}
/**
* GET /keka/callback
*
* Microsoft redirects here after login with ?code=... or ?error=...
*/
public function callback(Request $request): RedirectResponse
{
if ($request->filled('error')) {
return redirect()
->route('dashboard')
->with('error', 'Microsoft login was cancelled or failed: '.$request->get('error_description', $request->get('error')));
}
if (! $this->kekaOutlook->isValidState($request->get('state'))) {
return redirect()
->route('dashboard')
->with('error', 'Invalid login session. Please try connecting again.');
}
$code = $request->get('code');
if (! $code) {
return redirect()
->route('dashboard')
->with('error', 'No authorization code received from Microsoft.');
}
$result = $this->kekaOutlook->handleCallback($request->user(), $code);
if (! $result['success']) {
return redirect()
->route('dashboard')
->with('error', 'Failed to connect: '.($result['message'] ?? 'Unknown error'));
}
return redirect()->away($this->kekaWebsiteUrl());
}
/**
* POST /keka/disconnect
*
* Wipes stored tokens. User must go through Microsoft login again next time.
*/
public function disconnect(Request $request): RedirectResponse
{
$this->kekaOutlook->disconnect($request->user());
return redirect()
->route('dashboard')
->with('success', 'Keka has been disconnected.');
}
/**
* The Keka website URL the user lands on after a successful (silent) connection.
*/
private function kekaWebsiteUrl(): string
{
return (string) config('microsoft.keka_website_url', 'https://app.keka.com');
}
}

View File

@ -59,6 +59,7 @@ protected function casts(): array
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'keka_ms_token_expires_at' => 'datetime',
]; ];
} }

View File

@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
/**
* Handles Microsoft OAuth (delegated, authorization code flow) for Keka-Outlook integration.
*
* Flow:
* - connect() -> build Microsoft login URL
* - handleCallback() -> exchange code for tokens, store on user
* - getValidToken() -> return a valid access token, refreshing silently if expired
* - disconnect() -> wipe stored tokens for the user
*/
final class KekaOutlookService
{
private string $clientId;
private string $clientSecret;
private string $tenantId;
private string $redirectUri;
private array $scopes;
public function __construct()
{
$this->clientId = (string) config('microsoft.client_id');
$this->clientSecret = (string) config('microsoft.client_secret');
$this->tenantId = (string) config('microsoft.tenant_id');
$this->redirectUri = (string) config('microsoft.keka_redirect_uri');
$this->scopes = config('microsoft.keka_scopes', [
'openid',
'profile',
'email',
'offline_access',
'User.Read',
'Mail.Read',
'Mail.ReadWrite',
'Mail.Send',
]);
}
/**
* Build the Microsoft login URL the user is redirected to.
* A random "state" value is generated and stored in the session to prevent CSRF.
*/
public function getAuthUrl(): string
{
$state = Str::random(40);
session(['keka_ms_oauth_state' => $state]);
$query = http_build_query([
'client_id' => $this->clientId,
'response_type' => 'code',
'redirect_uri' => $this->redirectUri,
'response_mode' => 'query',
'scope' => implode(' ', $this->scopes),
'state' => $state,
// prompt=select_account forces account chooser on first connect.
'prompt' => 'select_account',
]);
return "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}";
}
/**
* Validate the "state" returned by Microsoft against the one stored in session.
*/
public function isValidState(?string $state): bool
{
$expected = session('keka_ms_oauth_state');
if (! $expected || ! $state) {
return false;
}
return hash_equals($expected, $state);
}
/**
* Exchange the authorization code for access + refresh tokens and store them on the user.
*
* @return array{success: bool, message?: string}
*/
public function handleCallback(User $user, string $code): array
{
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->redirectUri,
'scope' => implode(' ', $this->scopes),
]
);
if (! $response->successful()) {
return [
'success' => false,
'message' => $response->json('error_description', 'Failed to exchange code for token.'),
];
}
$data = $response->json();
$this->storeTokens($user, $data);
// Fetch the connected mailbox's email address for display purposes.
$profile = Http::withToken($data['access_token'])
->get('https://graph.microsoft.com/v1.0/me');
if ($profile->successful()) {
$user->keka_ms_email = $profile->json('mail') ?? $profile->json('userPrincipalName');
$user->save();
}
session()->forget('keka_ms_oauth_state');
return ['success' => true];
}
/**
* Persist tokens + expiry on the user model.
*/
private function storeTokens(User $user, array $tokenData): void
{
$user->keka_ms_access_token = $tokenData['access_token'];
$user->keka_ms_token_expires_at = now()->addSeconds((int) $tokenData['expires_in']);
// Microsoft only returns refresh_token if 'offline_access' scope was granted.
// If a new one isn't returned on refresh, keep the existing one.
if (! empty($tokenData['refresh_token'])) {
$user->keka_ms_refresh_token = $tokenData['refresh_token'];
}
$user->save();
}
/**
* Returns true if the user has connected Keka/Outlook (has a refresh token stored).
*/
public function isConnected(User $user): bool
{
return ! empty($user->keka_ms_refresh_token);
}
/**
* Get a valid access token for the user.
* Silently refreshes via refresh_token if the current access token has expired.
*
* @throws \RuntimeException if the user has not connected or refresh fails.
*/
public function getValidToken(User $user): string
{
if (! $this->isConnected($user)) {
throw new \RuntimeException('User has not connected Keka/Outlook.');
}
// Still valid? (1 minute buffer)
if (
$user->keka_ms_access_token
&& $user->keka_ms_token_expires_at
&& now()->lt($user->keka_ms_token_expires_at->subMinute())
) {
return $user->keka_ms_access_token;
}
return $this->refreshToken($user);
}
/**
* Use the stored refresh_token to obtain a new access_token silently.
*
* @throws \RuntimeException if Microsoft rejects the refresh token (user must reconnect).
*/
private function refreshToken(User $user): string
{
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'refresh_token',
'refresh_token' => $user->keka_ms_refresh_token,
'scope' => implode(' ', $this->scopes),
]
);
if (! $response->successful()) {
// Refresh token expired/revoked -> force user to reconnect.
$this->disconnect($user);
throw new \RuntimeException(
$response->json('error_description', 'Session expired. Please reconnect Keka.')
);
}
$data = $response->json();
$this->storeTokens($user, $data);
return $data['access_token'];
}
/**
* Disconnect: wipe stored tokens. User must go through login flow again to reconnect.
*/
public function disconnect(User $user): void
{
$user->keka_ms_access_token = null;
$user->keka_ms_refresh_token = null;
$user->keka_ms_token_expires_at = null;
$user->keka_ms_email = null;
$user->save();
}
}

View File

@ -1,5 +1,9 @@
{ {
"agents": ["claude_code", "copilot", "opencode"], "agents": [
"claude_code",
"copilot",
"opencode"
],
"cloud": false, "cloud": false,
"guidelines": true, "guidelines": true,
"mcp": true, "mcp": true,
@ -8,6 +12,7 @@
"skills": [ "skills": [
"fortify-development", "fortify-development",
"laravel-best-practices", "laravel-best-practices",
"fluxui-development",
"livewire-development", "livewire-development",
"pest-testing", "pest-testing",
"tailwindcss-development" "tailwindcss-development"

View File

@ -9,7 +9,7 @@
], ],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.5", "php": "^8.3",
"gehrisandro/tailwind-merge-laravel": "^1.4", "gehrisandro/tailwind-merge-laravel": "^1.4",
"laravel/fortify": "^1.34", "laravel/fortify": "^1.34",
"laravel/framework": "^13.7", "laravel/framework": "^13.7",

1131
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -125,4 +125,21 @@
'store' => env('APP_MAINTENANCE_STORE', 'database'), 'store' => env('APP_MAINTENANCE_STORE', 'database'),
], ],
// Redirect URI registered in Azure: http://localhost:8000/keka/callback
'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'),
// Where to send the user after a successful (or silent) connection.
'keka_website_url' => env('KEKA_WEBSITE_URL', 'https://app.keka.com'),
'keka_scopes' => [
'openid',
'profile',
'email',
'offline_access', // REQUIRED for refresh_token / silent reconnect
'User.Read',
'Mail.Read',
'Mail.ReadWrite',
'Mail.Send',
],
]; ];

16
config/microsoft.php Normal file
View File

@ -0,0 +1,16 @@
<?php
return [
'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'),
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'),
'keka_scopes' => ['openid',
'profile',
'email',
'offline_access',
'User.Read',
'Mail.Read',
'Mail.ReadWrite',
'Mail.Send']
];

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->text('keka_ms_access_token')->nullable();
$table->text('keka_ms_refresh_token')->nullable();
$table->timestamp('keka_ms_token_expires_at')->nullable();
$table->string('keka_ms_email')->nullable();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'keka_ms_access_token',
'keka_ms_refresh_token',
'keka_ms_token_expires_at',
'keka_ms_email',
]);
});
}
};

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keka</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gray-50 flex items-center justify-center">
<div class="bg-white rounded-2xl shadow-lg p-10 w-full max-w-md text-center space-y-6">
<div class="flex justify-center">
<div class="w-14 h-14 rounded-xl flex items-center justify-center" style="background:#FF6C37">
<span class="text-white font-bold text-2xl">K</span>
</div>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-800">Keka HR</h1>
<p class="text-gray-500 text-sm mt-1">Open Keka via Microsoft SSO</p>
</div>
<a href="{{ route('keka.open') }}" target="_blank" rel="noopener"
class="flex items-center justify-center gap-2 w-full text-white font-semibold py-3 px-6 rounded-xl transition duration-150"
style="background:#FF6C37">
Open Keka
</a>
<p class="text-xs text-gray-400">
Opens Keka in a new tab. If you're already signed into Microsoft in this browser, you'll be logged in automatically.
</p>
</div>
</body>
</html>

View File

@ -12,6 +12,7 @@
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController}; use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware}; use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
use App\Http\Controllers\KekaOutlookController;
Route::redirect('/', '/dashboard')->name('home'); Route::redirect('/', '/dashboard')->name('home');
@ -94,6 +95,14 @@
// Disconnect / revoke saved token // Disconnect / revoke saved token
Route::delete('disconnect', [EntraController::class, 'disconnect'])->name('disconnect'); Route::delete('disconnect', [EntraController::class, 'disconnect'])->name('disconnect');
});
Route::middleware(['auth', 'verified'])->prefix('keka')->name('keka.')->group(function (): void {
Route::get('/connect', [KekaOutlookController::class, 'connect'])->name('connect');
Route::get('/callback', [KekaOutlookController::class, 'callback'])->name('callback');
Route::post('/disconnect', [KekaOutlookController::class, 'disconnect'])->name('disconnect');
}); });
require __DIR__.'/settings.php'; require __DIR__.'/settings.php';