Patterns & Conventions
A few design patterns will keep your addon maintainable and consistent with phpVMS core. These are conventions, not hard requirements — but following them makes your code easier to test, easier to debug, and easier for other developers to contribute to.
Service layer
When a task spans multiple models or steps, write it as a Service class rather than putting the logic in a controller or model.
For example, filing a PIREP touches the Pirep model, the User model,
dispatches one or more events, updates ledgers, etc. Putting all of that in
one controller is fragile and hard to reuse. A service class ties the steps
together once and is callable from any entry point — web, API, console
command, scheduled job.
Generate one with:
php artisan module:make-service SampleReportService Sample
# → Modules/Sample/app/Services/SampleReportService.php
Inject any dependencies — other services, core models — via the constructor:
namespace Modules\Sample\Services;
use App\Models\Pirep;
use App\Services\PirepService;
class SampleReportService
{
public function __construct(
private PirepService $pirepSvc,
) {}
public function generateReport(int $pirepId): array
{
$pirep = Pirep::with(['user', 'aircraft'])->findOrFail($pirepId);
// … do the work
return [/* … */];
}
}
Core service classes live under
app/Services/
and are the canonical place to look for "how does phpVMS do X" — they're
also the safe entry points for mutating core data from your addon.
Dependency injection
Listeners, controllers, services, and console commands are all resolved out of the Laravel container, so any constructor dependencies are auto-injected. Use this for everything — services, core models that need bootstrapping, clients for external APIs:
namespace Modules\Sample\Listeners;
use App\Events\PirepAccepted;
use App\Services\PirepService;
use Illuminate\Support\Facades\Log;
class PirepAcceptedListener
{
public function __construct(
private PirepService $pirepSvc,
) {}
public function handle(PirepAccepted $event): void
{
Log::info('Received PIREP', [$event->pirep]);
}
}
You can pass as many dependencies into a constructor as you need. See
app/Http/Controllers/ in core for plenty of examples.
Binding interfaces to implementations
If you want to depend on an interface and swap implementations at runtime, register the binding in your module's service provider:
// Modules/Sample/app/Providers/SampleServiceProvider.php
public function register(): void
{
parent::register();
$this->app->bind(
\Modules\Sample\Contracts\ReportGenerator::class,
\Modules\Sample\Services\PdfReportGenerator::class,
);
}
Now anything type-hinting ReportGenerator gets the PDF implementation:
public function __construct(
private \Modules\Sample\Contracts\ReportGenerator $generator,
) {}
For singletons (one instance per request lifecycle), use singleton()
instead of bind().
See the Laravel automatic injection docs for the full pattern.
Scheduled commands
Add a scheduled command from inside your module's service provider:
// Modules/Sample/app/Providers/SampleServiceProvider.php
use Illuminate\Console\Scheduling\Schedule;
public function boot(): void
{
parent::boot();
$this->app->booted(function () {
$schedule = $this->app->make(Schedule::class);
$schedule->command('sample:cleanup-reports')->daily();
});
}
Generate the command with:
php artisan module:make-command CleanupReportsCommand Sample
Module-owned flights
If your module needs to generate flights for users to fly, attach them to
your module via the owner polymorphic relationship on the Flight
model.
When a flight is owned by a module, phpVMS core automation no longer applies to it (no automatic showing/hiding, no schedule-based filtering, etc.). You're responsible for defining how those flights behave and when they're visible.
There are two common ways to use the relationship.
Just set the owner type
If you don't have a model to attach the flight to, point owner_type at any
class your module exposes — typically a service provider:
$flight->owner_type = FreeFlightProvider::class;
$flight->save();
Core code only checks the owner_type value to know the flight is
module-owned, so any unique class name works.
Attach to a specific model
If your module has a model the flight relates to (e.g. a Tour):
use Modules\Sample\Models\Tour;
$tour = Tour::find(1);
$flight->owner_type = Tour::class;
$flight->owner_id = $tour->id;
$flight->save();
If your Tour model declares the inverse morphMany relationship to
flights, you can use Laravel's polymorphic helpers directly. See the
Laravel polymorphic relationship docs
for the full pattern.