Skip to content

Instantly share code, notes, and snippets.

@atomjoy
Last active September 1, 2026 12:03
Show Gist options
  • Select an option

  • Save atomjoy/876caeb7f859332ab9946ff071884975 to your computer and use it in GitHub Desktop.

Select an option

Save atomjoy/876caeb7f859332ab9946ff071884975 to your computer and use it in GitHub Desktop.
Laravel modules in custom location with Laravel 13, Vue and Inertia.

Laravel Custom Modules

Custom modules location modules/{Module} in Laravel project.

Composer

composer.json

{
    "autoload": {
        "psr-4": {
            "Mod\\": "modules"
        }
    },
}

Provider

bootstrap/providers.php

return [
    // Register ...
    Mod\User\UserServiceProvider::class,
];

Blade

app.blade.php

// Wymagane dla zmian w app.ts
@vite(['resources/css/app.css', 'resources/js/app.ts'])

Vue Inertia

app.ts

import { type DefineComponent } from 'vue';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createInertiaApp } from '@inertiajs/vue3';
import { initializeTheme } from '@/composables/useAppearance';
import AppLayout from '@/layouts/AppLayout.vue';
import AuthLayout from '@/layouts/AuthLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { initializeFlashToast } from '@/lib/flashToast';

const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

void createInertiaApp({
    title: (title) => (title ? `${title} - ${appName}` : appName),
    layout: (name) => {
        switch (true) {
            case name === 'Welcome':
                return null;
            case name.startsWith('auth/'):
                return AuthLayout;
            case name.startsWith('settings/'):
                return [AppLayout, SettingsLayout];
            default:
                return AppLayout;
        }
    },
    progress: {
        color: '#4B5563',
    },
    resolve: name => {
        // Jeśli nazwa komponentu inertia zawiera "::" (np. "User::Profile/Index")
        // Inertia::render('Blog::Website/Index'); -> Modules/Blog/Resources/Pages/Website/Index.vue
        if (name.includes('::')) {
            const [module, page] = name.split('::');
            // Szukamy pliku w katalogu konkretnego modułu            
            return resolvePageComponent(
                `../../modules/${module}/Resources/Pages/${page}.vue`,                
                import.meta.glob<DefineComponent>(`../../modules/**/Resources/Pages/**/*.vue`)
            );
        }        
        // Main pages
        return resolvePageComponent(`./pages/${name}.vue`, import.meta.glob<DefineComponent>('./pages/**/*.vue'));
    },
});

initializeTheme();
initializeFlashToast();

PHPUnit / Pest config

phpunit.xml

<testsuites>
    <!-- Dodaj tę sekcję do phpunit.xml -->
    <testsuite name="Modules">
        <directory>modules/*/Tests</directory>
    </testsuite>    
</testsuites>

Pest config

Zawsze dodawaj w teście z modułu!!!

use function Pest\Laravel\get;
use function Pest\Laravel\seed;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class);
uses(RefreshDatabase::class);

// Test dla nowoczesnego widoku Vue + Inertia
test('użytkownik może wyświetlić profil renderowany przez Vue i Inertia', function () {
    // Wyłącz weryfikację w Inertia do testu (nie wie o Pages z modułów):
    config(['inertia.testing.ensure_pages_exist' => false]);

    get('/vue/profil')
        ->assertStatus(200)
        ->assertInertia(function (Assert $page) {
            // Weryfikacja kontraktu danych z frontu i backendu
            $page->component('User::Profile')
                 ->has('name')
                 ->where('name', 'Jan Kowalski');            
            
            // Inteligentne sprawdzenie fizycznego istnienia pliku Vue w module
            $componentName = $page->toArray()['component'];
            [$module, $path] = explode('::', $componentName);
            $expectedFilePath = base_path("modules/{$module}/Resources/Pages/{$path}.vue");
            $this->assertFileExists(
                $expectedFilePath,
                "BŁĄD: Komponent Vue [{$componentName}] nie istnieje w ścieżce: {$expectedFilePath}"
            );
        });
});

Run Test

# Pojedyńczy test
php artisan test --filter=UserModuleTest

# Wszystkie moduły
php artisan test --testsuite=Modules

Laravel User Model

Update Laravel default User model php artisan config:publish auth.

'providers' => [
    'users' => [
        'driver' => 'eloquent',
        'model' => Mod\User\Models\User::class,
    ],
],

Module Service Provider

modules/User/UserServiceProvider.php

<?php

namespace Mod\User;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;

class UserServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Ładowanie systemu eventów komponentu
        $this->app->register(\Mod\User\Providers\EventServiceProvider::class);
        
        // Kiedykolwiek system poprosi o domyślny interfejs użytkownika, daj mu nasz model z modułu
        $this->app->bind(
            \Illuminate\Contracts\Auth\Authenticatable::class, 
            \Mod\User\Models\User::class
        );
        
        // $this->mergeConfigFrom(__DIR__ . '/Config/user.php', 'user');

        // Automatyczne zaimportowanie i rejestracja zewnętrznego Service Providera
        // $this->app->register(\Spatie\Permission\PermissionServiceProvider::class);
        
        // Możesz też zaimportować swój wewnętrzny sub-provider, np.:
        // $this->app->register(\Mod\User\Providers\AuthServiceProvider::class);

        // if ($this->app->isLocal()) { // Dev services packages }
    }

    public function boot(): void
    {
        // Rejestracja migracji modułu
        $this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');

        // Widoki
        $this->loadViewsFrom(__DIR__ . '/Views', 'user');

        // Trasy web
        Route::middleware('web')->namespace('Mod\User\Controllers')->group(__DIR__ . '/Routes/web.php');

        // Trasy api
        Route::middleware('api')->prefix('api')->namespace('Mod\User\Controllers\Api')->group(__DIR__ . '/Routes/api.php');

        // Translate
        $this->loadJsonTranslationsFrom(__DIR__ . '/Lang');
    }
}

Model

<?php

namespace Mod\User\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Mod\User\Database\Factories\UserFactory;

class User extends Authenticatable
{
    use HasFactory;

    protected $guarded = [];

    /**
     * Wskazanie dedykowanej fabryki dla modelu komponentu.
     */
    protected static function newFactory()
    {
        return UserFactory::new();        
    }

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'two_factor_confirmed_at' => 'datetime',
        ];
    }
}

Factory

<?php

namespace Mod\User\Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use Mod\User\Models\User;

class UserFactory extends Factory
{
    protected $model = User::class; // <-- Powiązanie z modelem modułu

    public function definition(): array
    {
        return [
            'name' => $this->faker->name(),
            'email' => $this->faker->unique()->safeEmail(),
            'password' => bcrypt('secret'),
        ];
    }
}

Tree

modules/User/
├── Config/
│   └── user.php                      # Konfiguracja modułu (np. config('user.per_page'))
│
├── Controllers/
│   ├── UserController.php            # Kontroler dla tradycyjnych tras WEB / Blade
│   └── Api/
│       └── UserController.php        # Kontroler dla tras API (zwracający JSON)
│
├── Middleware/            
│   └── IsAdmin.php                   # Przykładowe middleware sprawdzające uprawnienia
│
├── Database/
│   ├── Factories/
│   │   └── UserFactory.php           # Fabryka Eloquent dedykowana dla modelu modułu
│   ├── Migrations/
│   │   └── 2026_01_01_000000_...     # Migracje bazy danych (np. create_module_users_table)
│   └── Seeders/
│       └── UserModuleSeeder.php      # Seeder zasilający bazę (Jan Kowalski ID 1, Anna Nowak ID 2)
│
├── Events/
│   └── UserRegistered.php            # Klasa zdarzenia (Event DTO)
│
├── Lang/
│   ├── en.json                       # Tłumaczenia JSON dla języka angielskiego
│   └── pl.json                       # Tłumaczenia JSON dla języka polskiego
│
├── Listeners/
│   └── SendWelcomeEmail.php          # Słuchacz zdarzenia (Listener reagujący na rejestrację)
│
├── Models/
│   └── User.php                      # Model Eloquent powiązany z bazą danych i z UserFactory
│
├── Providers/
│   └── EventServiceProvider.php      # Pod-provider rejestrujący mapę Event -> Listener
│
├── Resources/
│   └── Pages/
│       └── Profile.vue               # Komponent Vue 3 renderowany przez Inertia.js (User::Profile)
│
├── Tests/
│   └── Feature/
│       └── UserModuleTest.php        # Testy Pest (Web, API z JSON, Seeder, asercja pliku Vue)
│
├── Views/
│   └── profile.blade.php             # Tradycyjny widok Blade (dostępny jako user::profile)
│
├── Routes/
│   ├── api.php                       # Plik z trasami API (automatyczny prefiks /api/users)
│   └── web.php                       # Plik z trasami WEB (np. /blade/profile, /vue/profil)
│
└── UserServiceProvider.php           # Główny Service Provider modułu (ładuje trasy, widoki, migracje, config)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment