Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Rotor Laravel Package

visavi/rotor

Rotor is a modular Laravel admin panel and CMS toolkit from Visavi. It helps you scaffold management interfaces with CRUD resources, authentication, menus, and a responsive UI, so you can manage users, content, and settings without building a backend from scratch.

View on GitHub
Deep Wiki
Context7

Getting Started

First, calibrate what you're adopting. visavi/rotor is not a package you composer require into an existing app — it's a full CMS application on Laravel 13 / PHP 8.3+, with the core in app/ and every site section (forum, blogs, gallery, downloads) shipped as a module in modules/. Day-to-day work means forking the repo and building modules, not wiring service providers into someone else's skeleton. If your project is a bespoke Laravel app that needs a forum, adopting Rotor wholesale is a bigger commitment than it looks; if you're building a community portal, it's a very fast start.

Minimal path to a running instance:

git clone https://github.com/visavi/rotor
cd rotor
docker compose up -d
docker compose exec rotor composer install
docker compose exec rotor php artisan migrate --seed   # or use the web installer
docker compose exec rotor php artisan module:migrate    # catch up enabled modules

Where to look first, in order:

  1. app/Support/ — Registry, Hook, Validator, HtmlSanitizer, plus the newer FeedService. This is the public extension API of the core. Read Registry and Hook before anything else; almost every non-trivial integration goes through them.
  2. modules/ — pick the smallest existing module (not Forum) and read it end to end: module.php, routes, controller, views, migration, changelog.md. That is the canonical shape you're expected to imitate. modules/Feedback and modules/MailNotify (newly extracted from core in 14.8.0) are good, small, current examples.
  3. bootstrap/app.php — global middleware registration. Read it, then leave it alone (see Gotchas).
  4. resources/js/ajax.js — the contract for declarative Ajax. You will use this constantly.
  5. database/migrations/ vs database/upgrades/ — two-directory schema discipline you must understand before your first schema change.

Your first use case should be the generator, not a hand-written route:

docker compose exec rotor php artisan make:module Reviews --model=Review --admin --api

This produces the module skeleton with translations, changelog, model registration, admin CRUD, and API scaffolding. Run it, then immediately go to /admin/modules and activate it — that activation is what runs the module's migrations on a fresh site (see Gotchas). Then:

docker compose exec rotor php artisan test modules/Reviews

If that loop works, you understand the package. Everything else is detail.

Second thing to learn: how the front page is chosen. Since 14.8.0 the homepage is a setting ("Homepage": the feed, or a module's page). Modules claim it with Registry::homepage(); a custom template pulls a mixed feed with FeedService::getLatest(). If your first requirement is "land users on my section instead of the news feed", this is the hook — not a route override.

Implementation Patterns

Module-scoped feature work. Modules are the unit of change: one directory, own routes, own views, own migrations, own tests, own changelog. Treat the core app/ as read-only unless you're deliberately contributing upstream. When you must touch core behaviour, prefer a hook over an edit — Hook lets modules inject into core rendering points (including the all-pages footer hook), so a module can own its own JS/CSS without core changes.

Homepage registration replaces the old hook. The homepageView hook is gone in 14.8.0. Register your landing page declaratively:

Registry::homepage('reviews', fn () => view('reviews::index', [
    'items' => FeedService::getLatest(),   // mixed latest across configured sections
]));

Then the site admin picks it under "Homepage" in site settings. Don't try to win by ordering routes — the feed route still exists and will shadow you.

Views are namespaced by module key. Address them as <snake_key>::<file>, resolved from the module's resources/views root. Don't try relative paths or view('modules.forum.index').

Schema changes are a two-file ritual. For the core: edit the base create_* migration in database/migrations/ (this defines a fresh install) and add a separate file in database/upgrades/ for existing sites. Big tables get their own upgrade file, one table per file. InstallController marks upgrades as already-run on fresh installs, so upgrades are effectively deploy-time scripts for live sites only.

Module migrations are a third case: they live in modules/<Name>/migrations/ and are not picked up by artisan migrate. For enabled modules, 14.8.0 adds the runner:

docker compose exec rotor php artisan module:migrate

It applies new migrations for enabled modules and flushes the settings cache; shipped deploy.php calls it right after artisan:migrate. Put it in your pipeline — but note it only covers already activated modules (see Gotchas).

CRUD controllers follow a fixed shape: validate → cast → persist → attach files → redirect()->with('success', ...). Three pieces matter:

// Text from a form is never a plain string in this codebase.
$title = $request->string('title')->value();      // '' not null, safe under strict_types
// Model casts handle the null/HTML boundary, not the controller:
protected $casts = [
    'text'    => HtmlCast::class,                  // editor HTML, sanitized on write
    'title'   => TextCast::class . ':nullable',
    'country' => PlaceCast::class,                 // added 14.8.0: trims, capitalizes
];

HtmlCast/TextCast also apply Registry::textFilter() (the Antimat profanity filter, or your own) on read — the database always holds the original. That's a deliberate design: you can change filters retroactively without a data migration, but it also means DB::table() reads bypass filtering. Don't write new antimat() calls; they're deprecated until 15.0. PlaceCast is the pattern to copy for any free-text location/name field: normalize on write, don't scatter ucfirst(trim(...)) through controllers.

File attachments go through FileService, not the model:

$rules = ['files.*' => FileService::rules($type)];   // extensions + size, per record type
$files->attach($model, $request->file('files', [])); // pending first, then request files

attach() deliberately orders pending uploads before request files so sort groups don't interleave. As of 14.8.0 the attachment order is meaningful: it defines the record's cover image and og:image, and the UI persists drag-and-drop reordering. FileService::rules() is now also what the upload form advertises as accepted extensions and what the API validates against — one source of truth, case-insensitive ("JPG" accepts photo.jpg). Pre-uploaded (pending) files count toward the record's total limit. $model->uploadFile() is legacy and skips video conversion. For "upload before the post exists", query File::query()->pending($type, $userId) rather than hand-rolling where('relate_id', 0). FileService::acceptsPending() excludes pending files from personal-message APIs — a pending file has no recipient yet.

Thumbnails can be dragged straight into the editor as a link — no re-upload — so don't build a second upload path for "insert image".

Ajax is declarative. Put data-ajax attributes in markup and return the standard envelope:

return response()->json([
    'success' => true,
    'html'    => view('reviews::partials.list', compact('items'))->render(),
    // optional: 'message', 'redirect'
]);

The client handler lives in resources/js/ajax.js; modules have no JS bundle of their own, so never write window.* handlers or expect a <script> inside an Ajax partial to execute. For a full module page that needs JS, @push('scripts'); for something needed everywhere, use the footer hook.

Flash messages render as toasts. Since 14.8.0, redirect()->with('success', ...) and Ajax responses surface as notyf notifications that auto-dismiss, collapse validation errors into one toast, and do not re-fire on browser back/forward. Keep emitting the same keys; assume the toast is transient (don't put anything in a flash message that must be read later) and don't build your own toast layer.

Feeds and "latest across a section" pages use CappableTrait:

->orderByDesc('created_at')->capped()->paginate(20)

This paginates over the first 1000 rows in SQL. Do not substitute ->limit(1000)->get() then paginate in PHP — you'll hydrate 1000 models per page view. capped() and withUserVote() are now part of the documented module API surface, so use them rather than reinventing them.

Per-user state is scoped, not joined. Use withUserVote() from PollableTrait for the current user's vote instead of a manual leftJoin('polls', ...). For the ?user= API parameter use apiUser() — since 14.8.0 that parameter is token-only and guests get 403, so don't hand-roll a guest fallback. Gate user pages on the site with the check.user middleware.

Modals and expanders are native elements. 14.8.0 moves modals to <dialog> and collapsing blocks to <details>: Esc and outside-click cancel, editing warns about unsaved text. Trigger a dialog with a link attribute, not JS:

<a href="#" data-dialog="#languageDialog">Language</a>

If you're porting a theme, the old _language_modal / _comment_edit_modal partials are now _language_dialog / _comment_edit_dialog.

Mail and feedback are modules now. MailNotify (unread reminders, birthdays, unsubscribe) and Feedback (routes to PM, works without a mail host) were extracted from core. Depend on them explicitly, and follow the mail convention: Blade component templates in resources/views/mailer/ plus a text twin in mailer/text/, styles confined to components/mail.

Testing pattern: module-scoped while iterating, full suite before merging.

docker compose exec rotor php artisan test modules/Forum
docker compose exec rotor php artisan test --filter=testVoteScope
docker compose exec rotor ./vendor/bin/pint --test modules/Reviews
docker compose exec rotor ./vendor/bin/phpstan analyse modules/Reviews --no-progress

Gotchas and Tips

--env=testing is a footgun that will hit your production database. Without a .env.testing, Laravel silently falls back to .env. The test environment is configured in phpunit.xml; never pass the flag. All DB-touching commands must run inside the container (docker compose exec rotor ...) — php artisan on the host cannot reach MySQL.

Strict MySQL changes how you write tests and bulk queries. Every NOT NULL column without a default must be supplied or you get error 1364. Never use truncate() or DDL in tests: an implicit commit survives RefreshDatabase's rollback and leaks state between tests — use delete(). And no partial-column upsert() (INSERT ... ON DUPLICATE KEY UPDATE demands every NOT NULL column); do bulk counter updates with update() + a CASE expression. Counters must be declared default(0).

artisan migrate still cannot see module migrations — but module:migrate now can, with caveats. ModuleServiceProvider doesn't call loadMigrationsFrom; a module's migrations only run on activation in /admin/modules, and module:migrate only catches up enabled modules. So: fresh environment or newly added module → activate it first; existing site upgrading a module → php artisan module:migrate (or let deploy.php do it). migrate and migrate:status will never list them. This bites hardest in CI: your pipeline needs an explicit activation step for new modules and module:migrate after artisan:migrate. Tests get module paths from tests/CreatesApplication.php. If a test fails with "table not found" for a module you just added, this is why.

modules/ may be symlinks to a sibling rotor-modules repo. grep -r does not follow symlinks and silently returns zero matches — use grep -R, or search the sibling repo directly. Same reasoning means a "file not found" in an IDE may just be a broken link.

Upgrading to 14.8.0 has three non-obvious steps. (1) Install MailNotify and Feedback from the module catalog, or outgoing mail stops and the contact form disappears. (2) If you used the Classic module, select "Classic homepage" in site settings — it no longer hijacks the feed on its own, and the homepageView hook it relied on is removed. (3) Re-save already-pinned news once: the pin flag was lost when the feed moved to its own table, and 14.8.0 only restores the ranking for rows saved after the fix. Also expect ?user= API callers to break (token required now) and check any custom theme for the renamed *_dialog partials and data-dialog links.

The @section('name', $expr) null-output trap. A single-line section whose expression evaluates to null produces a PHPUnit "did not close its own output buffer" risky test. Always terminate with ?? ''.

Empty form fields become null (ConvertEmptyStringsToNull), which collides with NOT NULL columns and with strict_types function signatures. 14.8.0 fixed one such TypeError in a core form, which is a reminder rather than a licence: keep using $request->string('key')->value() (''), and the casts (HtmlCast, TextCast, PlaceCast, or ::class . ':nullable' where NULL is semantically meaningful) rather than scattering ?? through controllers.

Don't reorganise global middleware. Session, SetLocale, and ApplyTheme are registered globally in bootstrap/app.php on purpose. Moving them into the web group makes 404s outside that group throw 500s instead. 14.8.0 fixed the other half of this problem — error pages before the theme is available now render without a layout instead of dying with "No hint path defined for [theme]", and errors carry their real HTTP status with JSON responses for API routes. Don't reintroduce a layout dependency in your own error views.

Module settings are boss-only by design. ModuleSettingController::getMiddleware() layers check.admin:boss onto the route, because saving writes arbitrary sets[] including core keys. Also: don't change the update(Request) signature — modules override it, and a signature change silently breaks them.

Version and changelog discipline. Log module edits under ## Unreleased in the module's changelog.md; do not bump the version in module.php — that's done when the release archive is built. Skipping the changelog isn't enforced, but it's the only history downstream sites get. Note that the module catalog now enforces core compatibility: modules built against 14.8.0 APIs (Registry::homepage(), module:migrate) declare a minimum core, so shipping to older sites is not a silent affair.

Language modules are prefixed (LangGerman, not German) because all modules live in one flat modules/ directory and IDs must not collide.

Admin module URLs are directory-addressed now. Use /admin/modules/social-auth (directory name). /admin/modules/module?module=Name and /mails still work but are declared bridges until 15.0 — don't write new links to them.

asset() returns a relative path when ASSET_URL=/. Outbound links — payment callbacks, webhooks, emails — must use url() or route(), or you'll ship broken absolute URLs. This matters more since 14.8.0 canonicalises URLs: /index.php/..., /public/..., and trailing slashes all 301 to the clean address. If you're asserting on Location headers in tests, assert the canonical target, not the request path — and don't add trailing slashes to your own redirects.

cron.php is a scheduler entry point, not a route. Hosts that only accept a file path in cron point at it; a web request to it returns 403 by design. If you're used to artisan schedule:run in cron, either works — pick one, and keep the admin "scheduler ok" badge in mind (warnings about scheduler/queue/mail can be dismissed now, but a fresh failure resurfaces them).

Email is Blade components, never Markdown mail. resources/views/mailer/x.blade.php plus a text twin in mailer/text/, with styles confined to components/mail. Markdown mail mangles logins and passwords containing _ and *. 14.8.0 also stopped HTML-escaping text in the plain-text body — a name with & arrives as-is, so don't double-encode.

Deprecation boundary: App\Classes\* is a bridge until 15.0; public core classes live in App\Support\* (Registry, Hook, Validator, HtmlSanitizer, and now FeedService). setFlash() still works, but new code uses redirect()->with('success', ...); consistency matters because Ajax responses and full-page redirects travel different code paths, and mixing them produces notifications on the wrong request. Fix imports opportunistically when you're already editing a module — don't do sweeping renames.

Frontend: prefer Bootstrap utilities, then a shared reusable class; page-specific CSS is a last resort. Prefer native <dialog> / <details> over new JS widgets. Leave build.cssTarget in vite.config.mjs alone — it preserves the min-width media queries older Safari and Android need. spatie/laravel-ignition is no longer a dependency, so don't reach for Ignition's share/stack UI when debugging locally.

Overall assessment: the package rewards you generously if you stay inside the module pattern — generator, casts (HtmlCast, TextCast, PlaceCast), FileService, declarative Ajax, CappableTrait, Registry::homepage() — and punishes improvisation (hand-copied modules, manual migrations, custom JS handlers, route-level homepage overrides) almost immediately. 14.8.0 is mostly a maturity release: the new module:migrate command and deploy.php wiring remove the worst part of the deploy story, native <dialog>/<details> remove a whole class of JS bugs, and the homepage setting replaces a hook with declarative config. The friction that remains is operational: Docker-only DB access, activation still being required before module:migrate can see a module, a strict-MySQL posture that makes sloppy schema/test code fail loudly, and an upgrade path that now depends on installing two extracted modules. Budget a day for the first module and the rest compounds quickly.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package