Instruction file imported from Oasis256/Nexo (
.github/instructions/nexopos-modules.instructions.md). Copyright stays with the author.
NexoPOS Module Development Guide
This comprehensive guide explains how to create, structure, and develop modules for NexoPOS. Modules extend the functionality of NexoPOS and follow a specific architecture pattern similar to Laravel applications.
Module Overview
NexoPOS modules are self-contained packages that extend the core functionality of the application. Each module is stored in the /modules directory and follows a standardized structure that allows for seamless integration with the main application.
Module Directory Structure
Modules are located in the /modules directory, where each module has its own folder named after the module's namespace. For example, a module with namespace FooBar would be stored in /modules/FooBar/.
Basic Module Structure
/modules/FooBar/
├── config.xml # Module configuration and metadata
├── manifest.json # Export/import file configuration (optional)
├── FooBarModule.php # Main module entry class
├── Casts/ # Custom Eloquent casts
├── Crud/ # Generated CRUD classes
├── Events/ # Event classes
├── Http/ # Controllers and Request classes
│ ├── Controllers/
│ └── Requests/
├── Jobs/ # Queue job classes
├── Lang/ # Language files
├── Listeners/ # Event listeners
├── Migrations/ # Database migrations
├── Models/ # Eloquent models
├── Providers/ # Service providers
├── Public/ # Public assets (symlinked)
├── Resources/ # Views and frontend assets
│ ├── Views/
│ ├── ts/ # TypeScript files
│ └── scss/ # SCSS files
├── Routes/ # Route definitions
│ ├── api.php
│ └── web.php
├── Services/ # Service classes
├── Settings/ # Module settings
├── Tests/ # Test files
└── Traits/ # Reusable traits
Module Configuration
config.xml
Every module must include a config.xml file that describes the module's metadata and signature. This is how NexoPOS recognizes and loads the module.
Structure:
<?xml version="1.0" encoding="UTF-8"?>
<module>
<namespace>FooBar</namespace>
<version>1.0.0</version>
<author>Your Name</author>
<name>Foo Bar Module</name>
<description>A sample module that demonstrates NexoPOS module structure</description>
</module>
Required Tags:
<module>: Root container tag for all module metadata<namespace>: Unique identifier for the module- Must be in PascalCase format
- Cannot start with a number
- No spaces or special characters allowed
- Examples:
FooBar,InventoryManager,SalesReport
<version>: Numeric version notation- Contains only numbers and dots
- No leading "v" (e.g.,
1.0.0, notv1.0.0) - Follow semantic versioning:
major.minor.patch
<author>: Name of the module author/developer<name>: Human-readable display name for the module<description>: Brief description of the module's functionality
manifest.json (Optional)
The manifest.json file controls which files are included or excluded when the module is exported. This is useful for packaging modules for distribution.
Structure:
{
"include": [
"config.xml",
"FooBarModule.php",
"Http/**",
"Models/**",
"Resources/Views/**"
],
"exclude": [
"Tests/**",
"node_modules/**",
".env",
"*.log"
]
}
Properties:
include: Array of relative paths (from module root) to include in exportsexclude: Array of relative paths (from module root) to exclude from exports
Both properties support glob patterns for flexible file matching.
Vite Configuration
NexoPOS is built on top of Vite, Vue and Tailwind. If module can use their own frontend framework, it's recommended to stick to this stack. Therefore, we'll create a default vite.config.js. We'll make use of the following packages:
- laravel-vite-plugin
- @vitejs/plugin-vue
- @tailwindcss/vite
- vite-plugin-mkcert (for HTTPS in development)
As Vue is already included on NexoPOS, it's not required to use it on our module. In fact, we want our component to work seamlessly with NexoPOS, we'll then use it's API. Typically here is how a vite.config.js looks like:
import { defineConfig, loadEnv } from 'vite';
import { fileURLToPath } from 'node:url';
import laravel from 'laravel-vite-plugin';
import mkcert from 'vite-plugin-mkcert';
import path from 'node:path';
import vuePlugin from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
const Vue = fileURLToPath(
new URL(
'vue',
import.meta.url
)
);
export default ({ mode }) => {
return defineConfig({
base: '/',
plugins: [
mkcert(),
vuePlugin(),
laravel({
hotFile: 'Public/hot',
input: [
'Resources/css/style.css',
'Resources/ts/main.ts',
],
refresh: [
'Resources/**',
]
}),
tailwindcss(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'Resources/ts'),
}
},
// Server configuration for HMR (Hot Module Replacement)
// See modules/OpusBackup/vite.config.js for reference
server: {
port: 3344, // Custom port (optional, adjust per module)
host: '127.0.0.1', // Bind to localhost
cors: true, // Enable CORS
hmr: {
protocol: 'wss', // WebSocket Secure for HMR
host: 'localhost', // HMR host
},
https: true, // Enable HTTPS with mkcert
},
build: {
outDir: 'Public/build',
manifest: true,
rollupOptions: {
input: [
'./Resources/css/style.css',
'./Resources/ts/main.ts',
],
}
}
});
}
Package.json Dependencies:
Ensure your package.json includes vite-plugin-mkcert:
{
"devDependencies": {
"laravel-vite-plugin": "^1.2.0",
"vite": "^6.3.5",
"vite-plugin-mkcert": "^1.17.6",
"@vitejs/plugin-vue": "^5.2.4",
"@tailwindcss/vite": "^4.1.8",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3",
"vue": "^3.5.16"
}
}
Main Module Class
Entry Point
Each module must have a main entry class named {Namespace}Module.php. For a module with namespace FooBar, the file would be FooBarModule.php.
File Location: /modules/FooBar/FooBarModule.php
Namespace Convention: All module files use the namespace pattern Modules\{ModuleNamespace}\{SubNamespace}
Example:
<?php
namespace Modules\FooBar;
use App\Services\Module;
class FooBarModule extends Module
{
public function __construct()
{
parent::__construct( __FILE__ );
}
}
Module Directory Details
Casts/
Store custom Eloquent casts following Laravel 11+ patterns:
<?php
namespace Modules\FooBar\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class MoneyCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
return number_format($value / 100, 2);
}
public function set($model, string $key, $value, array $attributes)
{
return (int) ($value * 100);
}
}
Crud/
Generated CRUD classes following NexoPOS CRUD patterns:
<?php
namespace Modules\FooBar\Crud;
use App\Services\CrudService;
use Modules\FooBar\Models\Product;
class ProductCrud extends CrudService
{
const AUTOLOAD = true;
const IDENTIFIER = 'foobar.products';
protected $table = 'foobar_products';
protected $model = Product::class;
protected $namespace = 'foobar.products';
// CRUD implementation...
}
Events/
Event classes for module-specific events:
<?php
namespace Modules\FooBar\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ProductCreated
{
use Dispatchable, SerializesModels;
public $product;
public function __construct($product)
{
$this->product = $product;
}
}
Http/
Controllers/
Module controllers following Laravel conventions:
<?php
namespace Modules\FooBar\Http\Controllers;
use App\Http\Controllers\DashboardController;
use Modules\FooBar\Models\Product;
class ProductController extends DashboardController
{
public function index()
{
return view('FooBar::products.index');
}
public function create()
{
return view('FooBar::products.create');
}
}
Requests/
Form request validation classes:
<?php
namespace Modules\FooBar\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ProductRequest extends FormRequest
{
public function authorize()
{
return auth()->user()->allowedTo('create.foobar.products');
}
public function rules()
{
return [
'name' => 'required|string|max:255',
'price' => 'required|numeric|min:0',
];
}
}
Jobs/
Queue job classes. Note that using anonymous jobs via dispatch(function() { ... }) is strictly forbidden. Always create a dedicated Job class.
<?php
namespace Modules\FooBar\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessProduct implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle()
{
// Job implementation
}
}
Lang/
Language files for localization:
Lang/
├── en.json
├── fr.json
└── es.json
Example en.json:
{
"Product Name": "Product Name",
"Create Product": "Create Product",
"Product created successfully": "Product created successfully"
}
Listeners/
Event listener classes:
<?php
namespace Modules\FooBar\Listeners;
use Modules\FooBar\Events\ProductCreated;
class ProductCreatedListener
{
public function handle(ProductCreated $event)
{
// Handle the event
}
}
Note that Listeners are automatically discovered if an event class is provided on the handle method.
Migrations/
Database migration files following Laravel conventions:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('foobar_products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->decimal('price', 10, 2);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('foobar_products');
}
};
However, migration file name aren't required to follow Laravel's timestamp pattern. For a migration that create tables, we'll use the prefix Create followed by the table name. For example: CreateFooBarTable.php.
For a migration that alters an existing table, we'll use the prefix Update followed by the table name. For example: UpdateFooBarTable.php.
Models/
Eloquent model classes:
<?php
namespace Modules\FooBar\Models;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $table = 'foobar_products';
protected $fillable = [
'name',
'price'
];
protected $casts = [
'price' => 'decimal:2'
];
}
Providers/
Service provider classes:
<?php
namespace Modules\FooBar\Providers;
use Illuminate\Support\ServiceProvider;
class FooBarServiceProvider extends ServiceProvider
{
public function register()
{
// Register services
}
public function boot()
{
// ...
}
}
There is no need to manually register the service provider as NexoPOS automatically discovers it.
Public/
Public assets accessible via URL (symlinked):
Public/
├── css/
│ └── module.css
├── js/
│ └── module.js
└── images/
└── logo.png
Assets are accessible at /modules/{namespace}/...
Resources/
Views/
Blade template files:
Resources/Views/
├── layouts/
│ └── master.blade.php
├── products/
│ ├── index.blade.php
│ ├── create.blade.php
│ └── edit.blade.php
└── partials/
└── header.blade.php
Example view:
@extends('layout.dashboard')
@section('layout.dashboard.body')
<div class="h-full flex-auto flex flex-col">
<div class="px-4 flex justify-between">
<h3 class="text-xl font-semibold">{{ __('Products') }}</h3>
<a href="{{ route('foobar.products.create') }}" class="btn btn-primary">
{{ __('Create Product') }}
</a>
</div>
<div class="px-4 flex-auto">
<ns-crud
identifier="foobar.products"
:columns="columns"
:actions="actions">
</ns-crud>
</div>
</div>
@endsection
Loading Vite Assets in Views
IMPORTANT: When including Vite-compiled assets in module Blade views, you must use the @moduleViteAssets directive instead of the standard Laravel @vite directive.
Syntax:
@moduleViteAssets('path/to/asset', 'ModuleNamespace')
Parameters:
- Asset Path (string): Relative path from module root directory (no leading slash)
- Module Namespace (string): The module identifier from
config.xml
Example:
@section( 'layout.dashboard.footer' )
@parent
@moduleViteAssets('Resources/ts/main.ts', 'FooBar')
@moduleViteAssets('Resources/css/style.css', 'FooBar')
<script>
document.addEventListener('DOMContentLoaded', function() {
// Your module initialization code
if (typeof myModuleFunction !== 'undefined') {
myModuleFunction();
}
});
</script>
@endsection
Common Patterns:
Loading multiple assets:
@moduleViteAssets('Resources/ts/main.ts', 'FooBar')
@moduleViteAssets('Resources/css/style.css', 'FooBar')
@moduleViteAssets('Resources/ts/admin-panel.ts', 'FooBar')
With conditional loading:
@if(auth()->user()->allowedTo('manage.foobar'))
@moduleViteAssets('Resources/ts/admin-features.ts', 'FooBar')
@endif
Common Mistakes to Avoid:
❌ Wrong - Using standard @vite:
@vite(['modules/FooBar/Resources/ts/main.ts'])
❌ Wrong - Including leading slash:
@moduleViteAssets('/Resources/ts/main.ts', 'FooBar')
❌ Wrong - Using full path:
@moduleViteAssets('modules/FooBar/Resources/ts/main.ts', 'FooBar')
✅ Correct:
@moduleViteAssets('Resources/ts/main.ts', 'FooBar')
How It Works:
The @moduleViteAssets directive:
- Resolves the correct module path automatically
- Reads the module's Vite manifest file (
Public/build/.vite/manifest.json) - Includes the properly hashed and versioned assets
- Works with hot-reload during development (
npm run dev) - Ensures proper cache busting in production
Build Output Structure:
After running npm run build, your assets will be in:
modules/FooBar/Public/build/
├── .vite/
│ └── manifest.json
└── assets/
├── main-[hash].js
└── style-[hash].css
The directive automatically resolves these hashed filenames from the manifest.
TypeScript (ts/)
TypeScript files for frontend functionality:
// Resources/ts/components/ProductManager.ts
export class ProductManager {
private products: Product[] = [];
async loadProducts(): Promise<Product[]> {
const response = await fetch('/api/foobar/products');
this.products = await response.json();
return this.products;
}
}
Vue Component Injection (Recommended Approach):
NexoPOS already creates a Vue app instance for the dashboard. Instead of creating a separate Vue app, modules should inject components into the existing NexoPOS Vue instance. This approach offers several benefits:
- ✅ Access to all NexoPOS built-in components (ns-button, ns-input, ns-crud, etc.)
- ✅ Access to global state and services (nsHttpClient, nsSnackBar, etc.)
- ✅ Consistent styling with NexoPOS dashboard
- ✅ Smaller bundle size (Vue is not duplicated)
The trade-off is that custom components must be defined as TypeScript files (not .vue files) for proper injection.
IMPORTANT: Use Options API, Not Composition API
NexoPOS components should use the Options API (data, methods, mounted) rather than the Composition API (setup, ref, onMounted). This aligns with NexoPOS's architecture where defineComponent is globally available and Vue's reactivity is handled by the framework.
Step 1: Create Component as TypeScript File
Instead of .vue files, create your component as a TypeScript file that exports a Vue component definition using Options API:
// Resources/ts/components/NsMyComponent.ts
export default defineComponent({
name: 'NsMyComponent',
data() {
return {
loading: false,
data: []
};
},
mounted() {
this.loadData();
},
methods: {
loadData() {
this.loading = true;
nsHttpClient.get('/api/my-module/data')
.subscribe({
next: (response: any) => {
this.data = response;
this.loading = false;
},
error: (error: any) => {
nsSnackBar.error(error.message || 'Failed to load data');
this.loading = false;
}
});
}
},
template: `
<div class="p-4">
<div v-if="loading" class="flex justify-center">
<ns-spinner></ns-spinner>
</div>
<div v-else>
<div v-for="item in data" :key="item.id" class="mb-2 p-2 border rounded">
{{ item.name }}
</div>
</div>
</div>
`
});
Key Points:
- ❌ Don't import
defineComponentfrom Vue - it's globally available - ❌ Don't use Composition API (
setup,ref,onMounted) - ✅ Use Options API (
data,methods,mounted) - ✅ Use
this.propertyinstead ofproperty.value - ✅ Return plain objects from
data(), not refs
Step 2: Register Component in Entry File
In your main TypeScript entry file, register the component with the NexoPOS Vue instance:
// Resources/ts/main.ts
import NsMyComponent from './components/NsMyComponent';
// Register component with NexoPOS Vue instance
// The component will be available as <ns-my-component> in templates
if (typeof nsExtraComponents !== 'undefined') {
nsExtraComponents['ns-my-component'] = NsMyComponent;
}
Step 3: Load Assets BEFORE NexoPOS in Blade Template
CRITICAL: Module assets must be loaded before the @parent directive in the footer section. This ensures your components are registered before NexoPOS initializes its Vue app.
IMPORTANT: Vue components in modules MUST be placed inside a container with id="dashboard-content" to work properly. This is the mounting point where NexoPOS initializes the Vue app for dashboard pages.
@extends('layout.dashboard')
@section('layout.dashboard.body')
<div class="h-full flex-auto flex flex-col">
@include('common.dashboard-header')
<!-- CRITICAL: Use id="dashboard-content" for Vue components to work -->
<div class="px-4 flex-auto flex flex-col" id="dashboard-content">
<h2 class="text-2xl font-bold mb-4">{{ __m('My Page', 'MyModule') }}</h2>
<!-- Your component will be rendered here -->
<ns-my-component></ns-my-component>
</div>
</div>
@endsection
@section('layout.dashboard.footer.inject')
@moduleViteAssets('Resources/ts/main.ts', 'MyModule')
@endsection
Important Notes:
- Use
@section('layout.dashboard.footer.inject')instead of@push('footer-scripts'). This section is specifically designed for injecting module components before NexoPOS initializes. - Always wrap Vue components in a container with
id="dashboard-content"- this is where NexoPOS mounts its Vue app instance for dashboard pages. - Without the
dashboard-contentID, your components will not be rendered or will throw errors.
Step 4: TypeScript Declarations for NexoPOS Globals
Create type declarations for NexoPOS global variables:
// Resources/ts/types.d.ts
// NexoPOS component registry
declare const nsExtraComponents: Record<string, any>;
// Vue defineComponent (globally available)
declare const defineComponent: any;
// HTTP Client (RxJS-based)
declare const nsHttpClient: {
get(url: string, config?: any): { subscribe: (handlers: { next: Function; error: Function }) => void };
post(url: string, data?: any, config?: any): { subscribe: (handlers: { next: Function; error: Function }) => void };
put(url: string, data?: any, config?: any): { subscribe: (handlers: { next: Function; error: Function }) => void };
delete(url: string, config?: any): { subscribe: (handlers: { next: Function; error: Function }) => void };
};
// Snackbar notifications
declare const nsSnackBar: {
success(message: string, title?: string, options?: any): void;
error(message: string, title?: string, options?: any): void;
info(message: string, title?: string, options?: any): void;
};
// Popup system
declare const Popup: {
show(component: any, params?: any, config?: any): any;
};
// Built-in popup components
declare const nsAlertPopup: any;
declare const nsConfirmPopup: any;
declare const nsPromptPopup: any;
declare const nsSelectPopup: any;
declare const nsMedia: any;
declare const nsPosLoadingPopup: any;
// Localization
declare function __(key: string): string;
declare function __m(key: string, module: string): string;
Complete Example: Provider Management Component
// Resources/ts/components/NsProviderManager.ts
interface Provider {
id: string;
name: string;
icon: string;
connected: boolean;
description: string;
}
export default defineComponent({
name: 'NsProviderManager',
data() {
return {
providers: [] as Provider[],
loading: true
};
},
mounted() {
this.loadProviders();
},
methods: {
loadProviders() {
this.loading = true;
nsHttpClient.get('/api/my-module/providers')
.subscribe({
next: (response: Provider[]) => {
this.providers = response;
this.loading = false;
},
error: (error: any) => {
nsSnackBar.error(error.message || 'Failed to load providers');
this.loading = false;
}
});
},
connectProvider(providerId: string) {
nsHttpClient.post(`/api/my-module/providers/${providerId}/connect`)
.subscribe({
next: (response: any) => {
if (response.redirect_url) {
window.location.href = response.redirect_url;
} else {
nsSnackBar.success('Provider connected successfully');
this.loadProviders();
}
},
error: (error: any) => {
nsSnackBar.error(error.message || 'Failed to connect provider');
}
});
},
disconnectProvider(providerId: string) {
Popup.show(nsConfirmPopup, {
title: 'Disconnect Provider',
message: 'Are you sure you want to disconnect this provider?',
onAction: (confirmed: boolean) => {
if (confirmed) {
nsHttpClient.delete(`/api/my-module/providers/${providerId}`)
.subscribe({
next: () => {
nsSnackBar.success('Provider disconnected');
this.loadProviders();
},
error: (error: any) => {
nsSnackBar.error(error.message || 'Failed to disconnect');
}
});
}
}
});
}
},
template: `
<div class="ns-box rounded-lg">
<div class="ns-box-header p-4 border-b border-box-edge">
<h3 class="text-lg font-semibold">Cloud Providers</h3>
</div>
<div class="ns-box-body p-4">
<div v-if="loading" class="flex justify-center py-8">
<ns-spinner></ns-spinner>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="provider in providers" :key="provider.id"
class="ns-box p-4 rounded-lg border border-box-edge">
<div class="flex items-center mb-3">
<i :class="'las ' + provider.icon + ' text-3xl mr-3'"></i>
<div>
<h4 class="font-semibold">{{ provider.name }}</h4>
<span v-if="provider.connected"
class="text-xs text-success-tertiary">Connected</span>
<span v-else class="text-xs text-warning-tertiary">Not Connected</span>
</div>
</div>
<p class="text-sm text-secondary mb-4">{{ provider.description }}</p>
<div class="flex justify-end">
<ns-button v-if="provider.connected"
type="error"
@click="disconnectProvider(provider.id)">
Disconnect
</ns-button>
<ns-button v-else
type="info"
@click="connectProvider(provider.id)">
Connect
</ns-button>
</div>
</div>
</div>
</div>
</div>
`
});
Entry file registration:
// Resources/ts/main.ts
import NsProviderManager from './components/NsProviderManager';
if (typeof nsExtraComponents !== 'undefined') {
nsExtraComponents['ns-provider-manager'] = NsProviderManager;
}
Alternative: Using .vue Files with Popup
If you prefer using .vue files, you can still use them with the Popup system (which creates its own Vue instance):
// Resources/ts/main.ts
import { Popup } from '@/libraries/popup';
import MyPopupComponent from './components/MyPopupComponent.vue';
// Export function to show popup from Blade templates
const showMyPopup = (data?: any) => {
return new Promise((resolve, reject) => {
Popup.show(MyPopupComponent, {
resolve,
reject,
data
});
});
};
// Make available globally
(window as any).myModuleName = {
showMyPopup
};
export { showMyPopup };
Then call from Blade:
<button onclick="myModuleName.showMyPopup({ id: 123 }).then(result => console.log(result))">
Open Popup
</button>
CSS with Tailwind CSS v4
IMPORTANT: NexoPOS modules use Tailwind CSS v4 which has significant changes from v3:
- No
tailwind.config.jsfile - Configuration is done directly in CSS - Prefix required for modules - Use a unique prefix to avoid class name collisions with core NexoPOS
- New
@importsyntax - Import Tailwind with theprefix()function
Module CSS Structure:
/* Resources/css/style.css */
@import "tailwindcss" prefix(modulecode);
Where modulecode is a short, unique identifier for your module (2-4 characters recommended).
Examples:
FooBarmodule →prefix(fb)NsQuickConfigmodule →prefix(qc)CloudDeployermodule →prefix(cd)InventoryManagermodule →prefix(im)
Using Prefixed Classes in Vue Components:
<template>
<div class="fb:max-w-3xl fb:mx-auto">
<h2 class="fb:text-2xl fb:font-bold fb:mb-6">Title</h2>
<p class="fb:text-gray-600 fb:mb-4">Description</p>
<input
type="text"
class="fb:w-full fb:border fb:border-gray-300 fb:rounded-lg fb:px-4 fb:py-2 focus:fb:ring-2 focus:fb:ring-blue-500"
/>
<button class="fb:bg-blue-500 fb:text-white fb:px-4 fb:py-2 fb:rounded hover:fb:bg-blue-600">
Submit
</button>
</div>
</template>
Key Points:
- Every Tailwind class must have the prefix -
fb:text-xlnottext-xl - Responsive breakpoints MUST come after the module prefix -
fb:md:text-2xl✅ correct,md:fb:text-2xl❌ wrong - Pseudo-classes also come after module prefix -
hover:fb:bg-blue-600❌ wrong,fb:hover:bg-blue-600✅ correct - Combined responsive + pseudo-class -
fb:md:hover:bg-blue-600(prefix → breakpoint → pseudo → utility) - No tailwind.config.js needed - Tailwind v4 doesn't use it
- Prevents collisions - Your
fb:text-xlwon't conflict with core'stext-xl
Complete Example:
/* Resources/css/style.css */
@import "tailwindcss" prefix(fb);
/* Optional: Add custom CSS for your module */
.fb-custom-component {
/* Custom styles that aren't Tailwind utilities */
background-image: linear-gradient(to right, #4f46e5, #7c3aed);
}
<!-- Resources/ts/components/ProductCard.vue -->
<template>
<div class="fb:bg-white fb:rounded-lg fb:shadow-md fb:p-4 fb:hover:shadow-lg fb:transition-shadow">
<h3 class="fb:text-lg fb:font-semibold fb:text-gray-800 fb:mb-2">
{{ product.name }}
</h3>
<p class="fb:text-gray-600 fb:text-sm fb:mb-4">
{{ product.description }}
</p>
<div class="fb:flex fb:justify-between fb:items-center">
<span class="fb:text-xl fb:font-bold fb:text-green-600">
{{ formatPrice(product.price) }}
</span>
<button class="fb:bg-blue-500 fb:text-white fb:px-4 fb:py-2 fb:rounded fb:text-sm hover:fb:bg-blue-600 fb:transition-colors">
Add to Cart
</button>
</div>
</div>
</template>
Migration from Tailwind v3:
If you have existing code with Tailwind v3 (using tailwind.config.js):
- Delete
tailwind.config.js- No longer needed - Create/update
Resources/css/style.csswith@import "tailwindcss" prefix(yourprefix); - Add prefix to all Tailwind classes in your Vue components
- Update vite.config.js to ensure it includes the CSS file in the input array
Troubleshooting:
If you see errors like "Cannot apply unknown utility class border-gray-300":
- Check that your CSS file has the
@import "tailwindcss" prefix(...)directive - Verify all Tailwind classes in your Vue files use your prefix
- Ensure the CSS file is included in vite.config.js inputs
- Run
npm run buildto regenerate assets
SCSS (scss/) - Legacy
Note: SCSS with @apply directives is legacy and not recommended with Tailwind v4. Use the CSS + prefix approach above instead.
Routes/
Both web.php and api.php routes file doesn't need to be manually registered. NexoPOS discovers it automatically.
api.php
API route definitions:
<?php
use Illuminate\Support\Facades\Route;
use Modules\FooBar\Http\Controllers\Api\ProductController;
Route::prefix('foobar')->group(function () {
Route::apiResource('products', ProductController::class);
});
web.php
Web route definitions:
<?php
use Illuminate\Support\Facades\Route;
use Modules\FooBar\Http\Controllers\ProductController;
Route::prefix('dashboard/foobar')->group(function () {
Route::get('/products', [ProductController::class, 'index'])
->name('foobar.products.index');
Route::get('/products/create', [ProductController::class, 'create'])
->name('foobar.products.create');
});
Services/
Service classes for business logic:
<?php
namespace Modules\FooBar\Services;
class ProductService
{
public function createProduct(array $data)
{
// Business logic for creating products
return Product::create($data);
}
public function calculatePrice(Product $product)
{
// Complex pricing logic
return $product->base_price * $this->getTaxRate();
}
}
Settings/
Module setting classes:
<?php
namespace Modules\FooBar\Settings;
use App\Classes\SettingForm;
use App\Services\SettingsPage;
class FooBarSettings extends SettingsPage
{
const IDENTIFIER = 'foobar_settings';
public function getForm()
{
return SettingForm::form(
title: __('FooBar Settings'),
description: __('Configure FooBar module settings'),
tabs: [
SettingForm::tab(
identifier: 'general',
label: __('General'),
fields: [
// Setting fields
]
)
]
);
}
}
Tests/
PHPUnit test files:
<?php
namespace Modules\FooBar\Tests\Feature;
use Tests\TestCase;
use Modules\FooBar\Models\Product;
class ProductTest extends TestCase
{
public function test_can_create_product()
{
$data = [
'name' => 'Test Product',
'price' => 99.99
];
$response = $this->post('/api/foobar/products', $data);
$response->assertStatus(201);
$this->assertDatabaseHas('foobar_products', $data);
}
}
Traits/
Reusable trait classes:
<?php
namespace Modules\FooBar\Traits;
trait HasPrice
{
public function getFormattedPriceAttribute()
{
return '$' . number_format($this->price, 2);
}
public function scopeInPriceRange($query, $min, $max)
{
return $query->whereBetween('price', [$min, $max]);
}
}
Console/
⚠️ IMPORTANT: Modules should NOT register commands or define schedules in their ServiceProvider.
Command classes for the module:
<?php
namespace Modules\FooBar\Console;
use Illuminate\Console\Command;
class ProcessDataCommand extends Command
{
protected $signature = 'foobar:process-data';
protected $description = 'Process FooBar data';
public function handle()
{
// Command logic
$this->info('Processing data...');
}
}
Command Discovery:
- NexoPOS automatically discovers and registers commands from enabled modules
- Do NOT manually register commands in your module's ServiceProvider
- Do NOT define schedules in your module's ServiceProvider
- Commands are available once the module is enabled
Scheduling:
- If your module needs scheduled tasks, document them in your module's README
- Let the system administrator add the schedule to their application's schedule configuration
- Do NOT use
$this->app->booted()orSchedulein module ServiceProviders
Module Development Best Practices
1. Naming Conventions
- Module Namespace: PascalCase, no numbers at start, no special characters
- File Names: Follow Laravel conventions (PascalCase for classes)
- Database Tables: Use module prefix (e.g.,
foobar_products) - Routes: Use module prefix (e.g.,
foobar.products.index)
2. Module Changelog Documentation
IMPORTANT: When making significant changes to a module that require documentation, create a changelog file in the module's root directory.
Module Changelog Directory Structure
modules/YourModule/
├── CHANGELOG/
│ ├── YYYY-MM-DD-descriptive-heading-in-kebab-case.md
│ ├── YYYY-MM-DD-another-change.md
│ └── README.md
Changelog File Naming Convention
Module changelog files must follow this naming format:
YYYY-MM-DD-descriptive-heading-in-kebab-case.md
Examples:
2025-11-27-add-new-feature.md2025-11-27-update-api-endpoints.md2025-11-27-fix-critical-bug.md2025-12-01-improve-performance.md
Module Changelog Content Structure
# Change Title
**Date:** YYYY-MM-DD
**Type:** Feature | Bug Fix | Enhancement | Breaking Change
**Module:** ModuleName
**Version:** X.Y.Z
## Summary
Brief description of what changed and why.
## Changes Made
- List of specific changes
- Files modified or added
- New features or functionality
## Migration Required
If applicable, document any migration steps needed.
## Breaking Changes
If applicable, list any breaking changes and how to address them.
## Dependencies
List any new dependencies or version requirements.
## Related Issues
Link to any related GitHub issues or tickets.
When to Create a Module Changelog
Create a changelog for:
- New module features
- Breaking changes to module APIs
- Significant bug fixes
- Database schema changes in module
- Configuration changes
- Deprecated features
- Security updates
- Version updates
Example Module Changelog
# Add Product Import Feature
**Date:** 2025-11-27
**Type:** Feature
**Module:** FooBar
**Version:** 1.2.0
## Summary
Added bulk product import functionality allowing users to import products from CSV files with validation and error reporting.
## Changes Made
- Created ImportController for handling CSV uploads
- Added validation service for product data
- Implemented batch processing for large imports
- Added import history tracking
- Created new API endpoints for import operations
## Migration Required
Run the following migration:
```bash
php artisan module:migrate FooBar
Breaking Changes
None.
Dependencies
- Added league/csv: ^9.8
- Requires PHP 8.1+
Related Issues
- Closes #123
- Related to #456
**Note:** Module changelogs are separate from core NexoPOS changelogs (which go in `/changelogs/` at the project root). Only document changes specific to your module in the module's CHANGELOG directory.
### 3. Namespace Organization
All module classes should follow the namespace pattern:
Modules{ModuleNamespace}{SubNamespace}{ClassName}
Examples:
- `Modules\FooBar\Http\Controllers\ProductController`
- `Modules\FooBar\Models\Product`
- `Modules\FooBar\Services\ProductService`
### 3. Integration with NexoPOS
#### Permissions
Create module-specific permissions:
```php
// In module migration or provider
use App\Models\Permission;
$permission = Permission::firstOrNew(['namespace' => 'create.foobar.products']);
$permission->name = __m( 'Create products', 'FooBar' );
$permission->namespace = 'create.foobar.products';
$permission->description = 'Allow creating FooBar products';
$permission->save();
Hooks and Events
Utilize NexoPOS hooks for integration:
use App\Classes\AsideMenu;
// ...
// In module service provider
Hook::addFilter('ns-dashboard-menus', function($menus) {
$newMenus = AsideMenu::menu(
label: __m( 'Foobar', 'FooBar' ),
icon: 'la-box',
href: route( 'foobar.products.index' ),
permissions: [
'view.foobar.products'
]
);
$menus = array_insert_after($menus, 'inventory', $newMenus);
return $menus;
});
Or we add define submenus
use App\Classes\AsideMenu;
// ...
Hook::addFilter('ns-dashboard-menus', function($menus) {
$menus = array_insert_after($menu, 'inventory', AsideMenu::menu(
label: __m( 'Products', 'FooBar' ),
icon: 'la-box',
href: route( 'foobar.products.index' ),
permissions: [
'view.foobar.products'
]
));
return $menus;
});
CRUD Integration
Extend NexoPOS CRUD system:
use App\Services\CrudService;
use App\Classes\CrudForm;
use App\Classes\FormInput;
class ProductCrud extends CrudService
{
// Implement getTable(), getForm(), getActions() methods
// Follow NexoPOS CRUD patterns
}
4. Frontend Integration
Vue Components
Create reusable Vue components:
// Resources/ts/components/ProductManager.vue
export default {
name: 'ProductManager',
data() {
return {
products: []
}
},
mounted() {
this.loadProducts();
}
}
Tailwind CSS Classes
Use existing NexoPOS/Tailwind classes for consistency:
<div class="bg-white rounded-lg shadow-md p-4">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Product Details</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Content -->
</div>
</div>
5. Module Installation and Activation
Modules are automatically discovered by NexoPOS when placed in the /modules directory. The system reads the config.xml file to register the module.
Activation Process
- Upload module to
/modules/{ModuleName}/ - Ensure
config.xmlis properly configured - NexoPOS automatically discovers and loads the module
- Run migrations if needed
- Configure permissions and settings
Module Commands
Use Artisan commands for module management:
# Install module dependencies
php artisan module:install FooBar
# Run module migrations
php artisan module:migrate FooBar
# Publish module assets
php artisan module:publish FooBar
Troubleshooting
Common Issues
- Module not loading: Check
config.xmlsyntax and namespace - Routes not working: Verify route file loading in service provider
- Views not found: Ensure view paths are registered correctly
- Permissions not working: Check permission creation and assignment
- Assets not loading: Verify Vite build and
@moduleViteAssetsusage
Asset Loading Issues
Problem: Module assets (JS/CSS) not loading
Solutions:
-
Verify build output exists:
ls -la modules/YourModule/Public/build/Should contain
.vite/manifest.jsonandassets/directory -
Check manifest file:
cat modules/YourModule/Public/build/.vite/manifest.jsonShould contain entries for your compiled assets
-
Ensure using correct directive in Blade:
@moduleViteAssets('Resources/ts/main.ts', 'YourModule')NOT
@vite()or incorrect paths -
Verify module namespace matches config.xml:
<namespace>YourModule</namespace> -
Clear all caches:
php artisan cache:clear php artisan view:clear php artisan config:clear -
Rebuild assets:
cd modules/YourModule npm install npm run build -
Check browser console for 404 errors or JavaScript errors
-
Verify vite.config.js has correct paths:
laravel({ hotFile: 'Public/hot', input: ['Resources/ts/main.ts', 'Resources/css/style.css'], refresh: ['Resources/**'] })
Problem: Hot reload not working in development
Solutions:
-
Run dev server:
cd modules/YourModule npm run dev -
Check
Public/hotfile exists while dev server is running -
Verify Vite is accessible at
http://localhost:5173
Problem: TypeScript compilation errors
Solutions:
-
Install dependencies:
npm install -
Check tsconfig.json has correct paths configuration
-
Create types.d.ts for NexoPOS API declarations
-
Restart IDE/editor to reload TypeScript language server
Debugging Tips
- Check Laravel logs for module loading errors:
storage/logs/laravel.log - Verify namespace consistency across all files
- Ensure proper service provider registration
- Use browser DevTools Network tab to check asset requests
- Check Vite build output for compilation warnings
- Test with minimal module first, then add complexity
- Use Laravel's debugging tools (
dd(),dump(),Log::info()) - Verify module directory permissions (755 for directories, 644 for files)
Development Workflow Best Practices
- Start with structure: Create module skeleton first
- Build incrementally: Add features one at a time
- Test early: Run
npm run buildafter adding new assets - Clear caches often: After any configuration changes
- Use version control: Commit working states frequently
- Document changes: Keep README.md updated with features
- Follow conventions: Match NexoPOS coding standards
- Check examples: Reference existing modules like
NsPageBuilder,CloudDeployer
This comprehensive guide provides the foundation for creating robust, well-structured modules that integrate seamlessly with the NexoPOS ecosystem.