kolydart / laravel
A collection of laravel helper classes
Requires
- php: >=7.4
Requires (Dev)
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/events: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- illuminate/translation: ^10.0|^11.0|^12.0
- laravel/framework: ^10.0|^11.0|^12.0
- mockery/mockery: ^1.6
- phpunit/phpunit: ^10.0|^11.0
Suggests
- doctrine/dbal: Required to generate ERDs.
README
Document Purpose: Technical documentation for Laravel package providing ordered pivot relationships management tools. Describes installation, usage, and API reference.
A collection of Laravel helper classes including ordered pivot relationships functionality.
Additional documentation is available in the
src/docs/folder.
⚠️ This repository is public. Everything committed here — code, documentation, tests, commit messages — is world readable and permanently recorded in the git history, where a later commit cannot retract it. Contributions must therefore contain no hostnames or server names, internal domains or IP addresses, filesystem paths from real deployments, database or application names, client or project names, e-mail addresses, credentials, or references to private repositories and internal documents. Use neutral placeholders instead:
example.com,myapp,<host>,/path/to/app. Anything meaningful to only one organisation belongs in that organisation's private repository; what is documented here is the general mechanism.
Table of Contents
- Installation
- Security
- Audited Relations
- Ordered Pivot Relationships
- Additional Components
- Testing Helpers
- Testing
- License
Installation
composer require kolydart/laravel
The service provider will be automatically registered via Laravel's package auto-discovery.
Security
Notes for consumer applications. See the changelog for the fixes behind them.
The audit log grid renders escaped values — keep it that way
PowerGrid renders field closure return values as raw HTML (that is what lets PgAuditLog return <a href="…"> links). Audit properties and user names are attacker-controlled: a user who can edit any audited text field — their own profile name is enough — can store markup that executes in the browser of whoever opens the audit log.
Every such value in PgAuditLog is therefore passed through e(). If you subclass the component or add your own fields, escape anything that originates from the database:
->add('my_field', fn ($model) => e($model->some_user_supplied_value))
Restricting who can read the audit log
PgAuditLog performs no authorization of its own. Rendered without a :model parameter it exposes the whole audit_logs table — who did what, to which record, from which IP. Authorization is normally handled by the route that renders it. For defence in depth, name a Gate ability:
// config/kolydart.php 'audit_log' => [ 'view_ability' => 'audit_log_access', ],
The check runs in datasource(). It is null (no check) by default, so existing installations are unaffected until they opt in — set it only once the ability actually exists, or every audit grid will 403.
Auditable and sensitive columns
Auditable writes changed attributes into audit_logs.properties, which is readable by every role holding audit-log access. It drops password, remember_token, two_factor_code and two_factor_expires_at explicitly; everything else is filtered only by your model's $hidden.
If a model carries secrets under other names — API tokens, recovery codes, national identifiers — add them to $hidden on that model.
backend middleware denies by default
BackendAccess aborts with 403 for guests and for user models that do not implement has_backend_access(). Before this was the case it allowed both through, so a route group protected by backend alone was open. It is still correct to place auth ahead of it; the middleware no longer depends on you doing so.
Publishing config/kolydart.php
mergeConfigFrom() merges only the top level. A published config containing an impersonate key replaces that whole sub-array, so any key you omit goes missing rather than falling back to the package default. Diff your published copy against src/config/kolydart.php after upgrading.
Audited Relations
The HasAuditedRelations trait provides audited variants of BelongsToMany pivot operations. Audit entries are written on the parent model — never on pivot models — which avoids double-audits and lets you reverse-query from either side of the relation.
When to use
Use HasAuditedRelations on a parent model when you need to:
- audit
attach/detach/sync/toggleoperations on a relation - record additional pivot attributes (e.g.
role) - reorder a
BelongsToManyrelation silently (no phantom audit entries) - avoid the double-audit problem caused by pivot models using
Auditable
use Kolydart\Laravel\App\Traits\HasAuditedRelations; class Item extends Model { use HasAuditedRelations; public function agents() { return $this->belongsToMany(Agent::class)->withPivot('role', 'order'); } public function instruments() { return $this->belongsToMany(Instrument::class)->withPivot('order'); } } // In a controller: $item->auditedSyncRoledPivot('agents', $request->input('agents', [])); $item->auditedSyncWithOrder('instruments', $request->input('instruments', [])); $item->auditedSync('languages', $request->input('languages', [])); $item->auditedAttach('places', $place); $item->auditedDetach('places', $place);
API
| Method | Use case |
|---|---|
auditedAttach(string $relation, $id, array $attrs = [], bool $touch = true) |
Single (or batch) attach with audit |
auditedDetach(string $relation, $ids = null, bool $touch = true): int |
Detach with audit. null detaches all. |
auditedSync(string $relation, $ids, bool $detaching = true): array |
Full sync, equivalent to sync() |
auditedSyncWithoutDetaching(string $relation, $ids): array |
Wrapper around auditedSync(..., false) |
auditedToggle(string $relation, $ids, bool $touch = true): array |
Toggle with audit |
auditedSyncWithOrder(string $relation, array $ids, string $orderColumn = 'order'): array |
Ordered sync — smart-diff, silent reorder |
auditedSyncRoledPivot(string $relation, array $rows, string $roleAttribute = 'role', string $defaultRole = 'creator'): array |
Sync where identity is (related_id, role). Role change = detach + attach. |
Each method is wrapped in a transaction so that pivot mutation and audit write succeed or fail atomically.
Audit payload
{
"action": "attach" | "detach" | "update",
"relation": "agents",
"role": "creator",
"related_id": 42,
"related_type": "App\\Models\\Agent",
"related_label": "John Doe"
}
The audit description is relation_attach, relation_detach, or relation_update, and subject_type / subject_id always point to the parent model.
Important: do not use Auditable on pivot models
The recommended strategy is parent-side audits only. Pivot models (those extending Illuminate\Database\Eloquent\Relations\Pivot) should not use Auditable. Otherwise, a single attach()/detach() produces two audit entries: one parent-side (from HasAuditedRelations) and one pivot-side (from the pivot's Auditable).
Migration guide for existing installations
If you currently rely on pivot-side Auditable, migrate as follows:
- Locate pivot models using
Auditablewhose parent has, or will have,HasAuditedRelations:grep -rln "use App\\\\Traits\\\\Auditable" app/Models/ | xargs grep -l "extends Pivot"
- Locate raw
sync()/attach()/detach()/ legacysyncWithOrder/syncRoledPivotcalls in controllers that operate on audited relations:grep -rn "->sync(\|->attach(\|->detach(\|syncWithOrder\|syncRoledPivot" app/Http/Controllers/ --include="*.php"
- Replace those calls with the
audited*equivalents (auditedSync,auditedSyncWithOrder,auditedSyncRoledPivot). - Remove
use Auditable;from the matching pivot models — both the trait use and theApp\Traits\Auditableimport. - Add
HasAuditedRelationsto any parent model not yet using it.
After migration, audited operations emit a single parent-side audit entry per affected related record.
Ordered Pivot Relationships
This package provides functionality to maintain order in many-to-many (pivot) relationships. This abstraction allows you to preserve the selection order of related models, which is particularly useful for forms where the order of selection matters.
Which sync helper? The
syncWithOrder()helpers inHasOrderedPivotandHandlesOrderedPivotare smart-diff and phantom-event-free, and are the canonical path for unaudited models. They do not produce audit entries — when the parent model usesHasAuditedRelations, useauditedSyncWithOrder()instead. All three compute their diff through the same implementation (App\Support\OrderedPivotSync::diff()), so ordering semantics are identical: 1-indexed, following the submitted array order. Only the application differs — the audited path pairs each attach/detach with an audit entry and wraps the whole operation in a transaction.
Quick Start
1. Create migration for order column:
php artisan make:ordered-pivot-migration paper_user --order-column=order --after=user_id
This creates a migration that adds an order column to the paper_user pivot table.
2. Update your model:
<?php namespace App; use Illuminate\Database\Eloquent\Model; use Kolydart\Laravel\App\Traits\HasOrderedPivot; class Paper extends Model { use HasOrderedPivot; public function users() { return $this->orderedBelongsToMany(User::class) ->withPivot('order') ->orderBy('paper_user.order'); } }
3. Update your controller:
<?php namespace App\Http\Controllers; use Kolydart\Laravel\App\Traits\HandlesOrderedPivot; class PaperController extends Controller { use HandlesOrderedPivot; public function store(Request $request) { $paper = Paper::create($request->validated()); $this->syncWithOrder($paper, 'users', $request->input('users', [])); return redirect()->route('papers.index'); } public function edit(Paper $paper) { $users = User::pluck('name', 'id'); $selectedUsers = $this->getOrderedIds($paper, 'users'); return view('papers.edit', compact('paper', 'users', 'selectedUsers')); } }
4. Add assets to your build process:
For Vite (Laravel 9+):
Add to your vite.config.js:
import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; export default defineConfig({ plugins: [ laravel({ input: [ 'resources/css/app.css', 'resources/js/app.js', 'public/vendor/kolydart/js/ordered-select.js' // Add this line ], refresh: true, }), ], });
Then in your Blade layout:
@vite(['resources/css/app.css', 'resources/js/app.js', 'public/vendor/kolydart/js/ordered-select.js'])
For Laravel Mix:
Add to your webpack.mix.js:
mix.js('resources/js/app.js', 'public/js') .postCss('resources/css/app.css', 'public/css') .copy('public/vendor/kolydart/js/ordered-select.js', 'public/js/ordered-select.js');
Then in your Blade layout:
<script src="{{ asset('js/ordered-select.js') }}"></script>
Manual inclusion (current setup):
Publish the assets, then load them after Select2 and after whatever code calls .select2():
php artisan vendor:publish --tag=kolydart-ordered-pivot-js
<script src="{{ asset('vendor/kolydart/js/Sortable.min.js') }}"></script> <script src="{{ asset('vendor/kolydart/js/ordered-select.js') }}"></script>
Sortable.min.js (SortableJS 1.15.6) is bundled with the package and is only needed for drag-to-reorder. Omit it and everything else keeps working.
5. Use in your Blade templates:
{{-- Using the Blade component --}} <x-kolydart::ordered-select name="users" :options="$users" :selected="$selectedUsers ?? []" multiple class="form-control select2" /> {{-- Or manually with the ordered-select class --}} <select name="users[]" class="form-control select2 ordered-select" multiple> @foreach($users as $id => $name) <option value="{{ $id }}">{{ $name }}</option> @endforeach </select> {{-- Using Livewire component for dynamic options --}} @livewire('kolydart-ordered-select', [ 'name' => 'users', 'options' => $users, 'selected' => $selectedUsers ?? [], 'multiple' => true, 'allowAdd' => true, 'modelClass' => 'App\\User', 'displayField' => 'name', 'valueField' => 'id' ])
Components
1. Model Trait: HasOrderedPivot
Provides methods for models that need ordered relationships.
Methods:
orderedBelongsToMany()- Define an ordered many-to-many relationshipsyncWithOrder()- Sync related models with order preservationgetOrderedIds()- Get ordered IDs from a relationship
2. Controller Trait: HandlesOrderedPivot
Provides controller methods for handling ordered pivot relationships.
Methods:
syncWithOrder()- Sync a relationship with order preservationgetOrderedIds()- Get ordered IDs for form displayprepareOrderedRelationshipForEdit()- Prepare data for edit forms
3. Artisan Command: make:ordered-pivot-migration
Generates migrations for adding order columns to pivot tables.
Usage:
php artisan make:ordered-pivot-migration {table} [--order-column=order] [--after=column]
4. JavaScript Component: OrderedSelect
Preserves selection order in Select2 dropdowns and provides dynamic option management.
Methods:
OrderedSelect.init()- Auto-initialize all elements with the 'ordered-select' class or thedata-drag-reorderattributeOrderedSelect.enableDragReorder($select)- Make the selected tags draggable (see Drag-to-reorder)OrderedSelect.getOrderedValues($select)- Get selected values in orderOrderedSelect.setOrderedValues($select, values)- Set values in specific orderOrderedSelect.addOption($select, value, text, selected, preserveOrder)- Add new optionOrderedSelect.createAddForm($select, config)- Create modal for adding options
Drag-to-reorder
OrderedSelect keeps the order in which items were selected. On top of that, already-selected tags can be dragged into a new order. Opt in per select with the data-drag-reorder attribute:
<select name="pages[]" id="pages" class="form-control select2" multiple data-drag-reorder> @foreach($pages as $id => $page) <option value="{{ $id }}" @selected(in_array($id, $selectedPages))>{{ $page }}</option> @endforeach </select>
The <x-kolydart::ordered-select> component adds the attribute automatically for multiple selects; pass :drag-reorder="false" to switch it off.
Notes:
- Requires SortableJS. Load the bundled
vendor/kolydart/js/Sortable.min.jsbeforeordered-select.js. If SortableJS is missing the feature is simply skipped — selection-order behaviour is unaffected. - On drop, the tag order is mirrored onto the underlying
<option>elements, so the submittedname[]order matches what the user sees, andsyncWithOrder()/auditedSyncWithOrder()persist exactly that. - The Select2 search field stays pinned at the end of the tag list.
cursor: moveon the tags is injected by the script; no stylesheet to publish.- The select must already be initialised by Select2 —
ordered-select.jsretries for ~1s to accommodate late initialisation.
5. Blade Component: <x-kolydart::ordered-select>
Reusable component for ordered select fields.
6. Livewire Component: @livewire('kolydart-ordered-select')
Advanced component with dynamic option addition and real-time updates.
7. Blade Component: <x-kolydart::table-hide-empty-rows>
Hides table rows that contain empty cells. Useful for cleaning up tables with sparse data.
Properties:
excludedTables: CSS selector for tables to exclude (default:.power-grid-table)excludedContainers: CSS selector for containers to exclude (default:div.tab-pane)
Usage:
<x-kolydart::table-hide-empty-rows /> {{-- With custom exclusions --}} <x-kolydart::table-hide-empty-rows excludedTables=".my-custom-table" excludedContainers=".modal-body" />
Usage Examples
Example 1: Basic Paper-User Relationship
// Model class Paper extends Model { use HasOrderedPivot; public function users() { return $this->orderedBelongsToMany(User::class); } } // Controller class PaperController extends Controller { use HandlesOrderedPivot; public function store(StorePaperRequest $request) { $paper = Paper::create($request->all()); $this->syncWithOrder($paper, 'users', $request->input('users', [])); return redirect()->route('papers.index'); } public function edit(Paper $paper) { $users = User::pluck('name', 'id'); $selectedUsers = $this->getOrderedIds($paper, 'users'); return view('papers.edit', compact('paper', 'users', 'selectedUsers')); } }
Example 2: Custom Order Column
// Migration php artisan make:ordered-pivot-migration project_task --order-column=priority --after=task_id // Model class Project extends Model { use HasOrderedPivot; public function tasks() { return $this->orderedBelongsToMany(Task::class, null, null, null, null, null, 'priority'); } } // Controller $this->syncWithOrder($project, 'tasks', $taskIds, 'priority');
Example 3: Dynamic Option Addition
// Add "Add New User" functionality function addNewUser() { const $select = $('#users'); OrderedSelect.createAddForm($select, { title: 'Add New User', label: 'User Name', onSubmit: function(name, callback) { // Make AJAX call to create user $.post('/api/users', {name: name}, function(response) { callback(response.id, response.name); }); } }); }
Example 4: Using JavaScript Directly
// Initialize ordered select with custom options OrderedSelect.init(); // Get current order const orderedValues = OrderedSelect.getOrderedValues($('#my-select')); // Set specific order OrderedSelect.setOrderedValues($('#my-select'), [3, 1, 4, 2]); // Add new option dynamically OrderedSelect.addOption($('#my-select'), 'new-id', 'New Option', true, true);
API Reference
HasOrderedPivot Trait
orderedBelongsToMany()
public function orderedBelongsToMany( string $related, string $table = null, string $foreignPivotKey = null, string $relatedPivotKey = null, string $parentKey = null, string $relatedKey = null, string $orderColumn = 'order' ): BelongsToMany
syncWithOrder()
public function syncWithOrder( BelongsToMany $relationship, array $ids, string $orderColumn = 'order' ): void
getOrderedIds()
public function getOrderedIds( BelongsToMany $relationship, string $orderColumn = 'order' ): array
HandlesOrderedPivot Trait
syncWithOrder()
protected function syncWithOrder( Model $model, string $relationshipName, array $ids, string $orderColumn = 'order' ): void
getOrderedIds()
protected function getOrderedIds( Model $model, string $relationshipName, string $orderColumn = 'order' ): array
prepareOrderedRelationshipForEdit()
protected function prepareOrderedRelationshipForEdit( Model $model, string $relationshipName, string $orderColumn = 'order' ): array
OrderedSelect JavaScript
init()
OrderedSelect.init() // Auto-initializes all .ordered-select elements
getOrderedValues()
OrderedSelect.getOrderedValues($select) // Returns: Array
setOrderedValues()
OrderedSelect.setOrderedValues($select, values) // Returns: void
addOption()
OrderedSelect.addOption($select, value, text, selected = false, preserveOrder = true)
createAddForm()
OrderedSelect.createAddForm($select, config = { title: 'Add New Option', label: 'Name', onSubmit: function(text, callback) { /* custom logic */ } })
Blade Component
<x-kolydart::ordered-select name="field_name" :options="$options" :selected="$selected" :multiple="true" placeholder="Select options..." :required="false" class="additional-classes" :attributes="['data-custom' => 'value']" />
Livewire Component
@livewire('kolydart-ordered-select', [ 'name' => 'field_name', 'options' => $options, 'selected' => $selected, 'multiple' => true, 'allowAdd' => true, 'modelClass' => 'App\\Model', 'displayField' => 'name', 'valueField' => 'id' ])
Migration from Manual Implementation
If you have an existing manual implementation, here's how to migrate:
1. Replace Manual Traits
Before:
// Custom syncUsersWithOrder method in controller private function syncUsersWithOrder(Paper $paper, array $userIds) { $paper->users()->detach(); foreach ($userIds as $order => $userId) { $paper->users()->attach($userId, ['order' => $order + 1]); } }
After:
use HandlesOrderedPivot; // Use the trait method $this->syncWithOrder($paper, 'users', $userIds);
2. Update Model Relationships
Before:
public function users() { return $this->belongsToMany(User::class)->withPivot('order')->orderBy('paper_user.order'); }
After:
use HasOrderedPivot; public function users() { return $this->orderedBelongsToMany(User::class); }
3. Simplify JavaScript
Before:
$('#users').on('select2:select', function (e) { var element = e.params.data.element; var $element = $(element); $element.detach(); $(this).append($element); $(this).trigger('change'); });
After:
// Just add 'ordered-select' class for auto-initialization // Or call OrderedSelect.init() manually
4. Use Blade Component
Before:
<select name="users[]" id="users" class="form-control select2" multiple> {{-- Complex logic for ordering options --}} @if(isset($selectedUsers)) @foreach($selectedUsers as $userId) @if(isset($users[$userId])) <option value="{{ $userId }}" selected>{{ $users[$userId] }}</option> @endif @endforeach @endif @foreach($users as $id => $user) @if(!in_array($id, $selectedUsers ?? [])) <option value="{{ $id }}">{{ $user }}</option> @endif @endforeach </select>
After:
<x-kolydart::ordered-select name="users" :options="$users" :selected="$selectedUsers ?? []" />
Additional Components
1. AdminLteDevColor
Changes the AdminLTE sidebar color to a distinct blue (#001FA1) when the application environment is local. This helps distinguish development from production environments.
Usage:
<x-kolydart::admin-lte-dev-color />
2. Datatables
Auto-focuses the DataTables search input and hides the bulk delete button on index pages.
Usage:
<x-kolydart::datatables />
3. Edit Button
Raenders an "Edit" button if the current user has permission and the corresponding edit route exists for the resource.
Usage:
<x-kolydart::edit-button />
It automatically detects the current resource route (e.g., changes show to edit) and checks permissions using Gate.
4. Form Fields Size
Adds Bootstrap classes to form fields to standardize their size and layout.
Usage:
<x-kolydart::form-fields-size />
Custom Class:
<x-kolydart::form-fields-size class="col-md-4" />
5. Keyboard Shortcuts
Adds keyboard shortcuts for common actions:
Cmd/Ctrl + S: Submit the form (Save).Cmd/Ctrl + E: Click the edit button.
Usage:
<x-kolydart::keyboard-shortcuts />
6. Language Switcher
Displays a link to switch key language. Requires panel.available_languages config.
Usage:
<x-kolydart::language-switcher />
Configuration (config/panel.php):
'available_languages' => [ 'en' => 'English', 'el' => 'Greek', ],
7. Message Display
Displays session messages returned by the controller (success, warning, error).
Usage:
<x-kolydart::message-display />
8. Save Button Danger To Primary
Automatically changes "Save" buttons with btn-danger class to btn-primary. Useful for standardizing button styles.
Usage:
<x-kolydart::save-button-danger-to-primary />
9. Signature
Displays a "developed by kolydart" signature.
Usage:
<x-kolydart::signature />
With Copyright:
<x-kolydart::signature :copyright="true" />
10. Table Style Reset
Resets Bootstrap table styles by removing table-striped and table-bordered classes.
Usage:
<x-kolydart::table-style-reset />
Testing Helpers
InteractsWithDatatables
Trait that closes a common test gap for yajra/laravel-datatables: ordinary feature tests hit the HTML shell of a serverSide DataTable but never trigger the AJAX request the browser fires for the actual rows. The helper extracts the live columns config from the rendered page and replays it as XHR with a global-search value, so server-side errors (invalid column names, broken filters, missing relations) surface in CI instead of in production.
Mix it into your project's base test case and call assertDatatableAjaxLoads(string $route) from any feature test:
use Kolydart\Laravel\App\Testing\InteractsWithDatatables; abstract class TestCase extends BaseTestCase { use InteractsWithDatatables; }
#[Test] public function datatable_ajax_loads_without_errors(): void { $this->login_user('Secretary'); \App\Models\User::factory()->create(); $this->assertDatatableAjaxLoads(route('admin.users.index')); }
Full documentation, including what the helper catches and its limitations, is in src/docs/datatable-ajax-testing.md.
InteractsWithSmokeCrawler
Trait holding the shared machinery for the Laravel Dusk "browser smoke crawler" pattern: a test that visits every active GET page as a real browser and fails on browser-side errors (JS alerts, console SEVERE errors, rendered .alert-danger) that the headless PHPUnit suite cannot see. It provides route discovery, parametrized-URI resolution, the visit-and-assert loop, console-log filtering, and a deterministic page-settle wait — but not the #[Test] methods or getAdminUser(), which differ per project.
Mix it into tests/Browser/SmokeTest.php and declare the config it reads (all optional):
use Kolydart\Laravel\App\Testing\InteractsWithSmokeCrawler; class SmokeTest extends DuskTestCase { use InteractsWithSmokeCrawler; protected array $skipNames = ['admin.logout', 'admin.users.massDestroy']; protected array $skipUriPrefixes = ['_debugbar', 'livewire/', 'api/']; protected array $ignoredConsolePatterns = ['/conversions/']; protected string $modelNamespace = 'App\\Models\\'; // ... #[Test] methods + getAdminUser() ... }
Console noise is filtered in two passes: defaultIgnoredConsolePatterns() drops browser-chrome artefacts wherever they match (and is merged with the project's $ignoredConsolePatterns), while defaultIgnoredResourceHosts() drops third-party asset hosts only when the entry is also a load failure — so a transient Google Fonts outage does not fail an unrelated route, but a CSP refusal naming that same host still does.
Full documentation, including the configuration contract and how to drop a shared default, is in src/docs/browser-smoke-testing.md.
Testing
The package includes comprehensive tests. To run them:
cd vendor/kolydart/laravel composer test
License
GPL-3.0-or-later