Creating Apps
Build self-contained, distributable feature modules for the ZephyrPHP Marketplace. Apps extend your application with new routes, views, migrations, and functionality -- all packaged in a clean, installable format.
Overview
A ZephyrPHP marketplace app is a self-contained module that adds specific functionality to any ZephyrPHP project. Apps follow a well-defined structure and use a manifest file to describe themselves to the framework. When installed, an app can register routes, add sidebar menu items in the CMS, create database tables, and provide its own views -- all without modifying the host application's core code.
This guide walks through the full process of creating, structuring, and packaging an app for distribution on the ZephyrPHP Marketplace.
App Structure
A marketplace app follows a consistent directory structure. At minimum, an app needs a manifest file and a main class. More complex apps include routes, views, migrations, and configuration.
my-app/
+-- app.json # App manifest (required)
+-- App.php # Main app class (required)
+-- routes/
| +-- web.php # Web routes
| +-- api.php # API routes (optional)
+-- Controllers/
| +-- MyAppController.php
+-- Views/
| +-- index.twig
| +-- form.twig
+-- Migrations/
| +-- 001_create_my_app_table.php
+-- assets/
| +-- css/
| | +-- my-app.css
| +-- js/
| +-- my-app.js
+-- config/
+-- my-app.php # App configuration defaults
| File / Directory | Purpose | Required |
|---|---|---|
app.json |
Manifest describing the app's metadata, version, and entry point | Yes |
App.php |
Main class with lifecycle methods (register, boot, install, uninstall) | Yes |
routes/ |
Route definitions for web and API endpoints | No |
Controllers/ |
Controller classes handling request logic | No |
Views/ |
Twig templates for rendering HTML | No |
Migrations/ |
Database migration files for creating or modifying tables | No |
assets/ |
CSS, JavaScript, and image files | No |
config/ |
Default configuration files for the app | No |
The app.json Manifest
Every app must include an app.json file at its root. This manifest tells the framework how to load the app and provides metadata for the marketplace listing.
{
"name": "My App",
"slug": "my-app",
"version": "1.0.0",
"main": "App",
"namespace": "MyVendor\\MyApp",
"description": "What the app does"
}
Manifest Fields
| Field | Type | Description |
|---|---|---|
name |
string | Human-readable display name of the app |
slug |
string | URL-safe identifier, used for installation and routing (lowercase, hyphens only) |
version |
string | Semantic version number (e.g., 1.0.0, 2.1.3) |
main |
string | Class name of the main app entry point (relative to the namespace) |
namespace |
string | PHP namespace for the app's classes. Use double backslashes in JSON. |
description |
string | Short description of what the app does (shown in marketplace listings) |
The slug must be unique across the marketplace. Use your vendor prefix to avoid collisions -- for example, acme-contact-form rather than just contact-form. The slug is also used as the route prefix by default.
Extended Manifest Fields
You can include additional metadata in your manifest for richer marketplace listings:
{
"name": "My App",
"slug": "my-app",
"version": "1.0.0",
"main": "App",
"namespace": "MyVendor\\MyApp",
"description": "What the app does",
"author": "Your Name",
"license": "MIT",
"homepage": "https://github.com/myvendor/my-app",
"requires": {
"zephyrphp": ">=0.1",
"php": ">=8.2"
},
"permissions": [
"database",
"routes",
"sidebar"
]
}
The App Class
The main app class is the entry point for your marketplace app. It extends MarketplaceApp and implements lifecycle methods that the framework calls at different stages.
<?php
namespace MyVendor\MyApp;
use ZephyrPHP\Marketplace\MarketplaceApp;
class App extends MarketplaceApp
{
/**
* Register bindings, services, or configuration.
* Called every time the app is loaded.
*/
public function register(): void
{
// Merge default configuration
$this->mergeConfig('my-app', __DIR__ . '/config/my-app.php');
// Bind services to the container
$this->app->bind('my-app.service', function () {
return new \MyVendor\MyApp\Services\MyService();
});
}
/**
* Boot the app after all services are registered.
* Called on every request where the app is active.
*/
public function boot(): void
{
// Load routes
$this->loadRoutes(__DIR__ . '/routes/web.php');
// Register views directory
$this->loadViews(__DIR__ . '/Views', 'my-app');
// Add sidebar items to the CMS
$this->addSidebarItem('My App', '/my-app', 'grid');
}
/**
* Run when the app is first installed.
* Use for one-time setup like creating database tables.
*/
public function install(): void
{
// Run migrations
$this->runMigrations(__DIR__ . '/Migrations');
// Publish default configuration
$this->publishConfig('my-app', __DIR__ . '/config/my-app.php');
}
/**
* Run when the app is uninstalled.
* Clean up database tables, configuration, and files.
*/
public function uninstall(): void
{
// Roll back migrations
$this->rollbackMigrations(__DIR__ . '/Migrations');
// Remove published configuration
$this->removeConfig('my-app');
}
}
Lifecycle Methods
| Method | When Called | Typical Use |
|---|---|---|
register() |
Every request, during service registration phase | Bind services to the container, merge configuration defaults |
boot() |
Every request, after all services are registered | Load routes, register views, add sidebar items, set up event listeners |
install() |
Once, when the app is first installed | Run database migrations, publish config files, seed initial data |
uninstall() |
Once, when the app is removed | Drop database tables, remove config files, clean up stored files |
Adding Routes
Apps define their routes in files within the routes/ directory. Load them from the boot() method using $this->loadRoutes().
<?php
// routes/web.php
use ZephyrPHP\Core\Routing\Route;
Route::group(['prefix' => '/my-app', 'middleware' => ['auth']], function () {
Route::get('/', [MyVendor\MyApp\Controllers\MyAppController::class, 'index']);
Route::get('/create', [MyVendor\MyApp\Controllers\MyAppController::class, 'create']);
Route::post('/store', [MyVendor\MyApp\Controllers\MyAppController::class, 'store']);
Route::get('/{id}', [MyVendor\MyApp\Controllers\MyAppController::class, 'show']);
Route::delete('/{id}', [MyVendor\MyApp\Controllers\MyAppController::class, 'destroy']);
});
Always group your routes under a prefix that matches your app slug. This avoids conflicts with other apps and with the host application's routes.
Adding Sidebar Items
If your app has a CMS-facing interface, you can register sidebar navigation items from the boot() method:
public function boot(): void
{
// Simple sidebar link
$this->addSidebarItem('Contact Forms', '/my-app', 'mail');
// Sidebar item with sub-items
$this->addSidebarItem('My App', '/my-app', 'grid', [
['label' => 'Dashboard', 'url' => '/my-app'],
['label' => 'Settings', 'url' => '/my-app/settings'],
]);
}
The third parameter is an icon name from the icon set used by the ZephyrPHP CMS dashboard. The optional fourth parameter defines sub-navigation items.
Adding Views
Register your app's views directory in the boot() method. Views are namespaced to avoid collisions with other templates.
// In boot()
$this->loadViews(__DIR__ . '/Views', 'my-app');
Then render views from your controller using the namespace prefix:
<?php
namespace MyVendor\MyApp\Controllers;
use ZephyrPHP\Core\Controllers\Controller;
class MyAppController extends Controller
{
public function index()
{
$items = MyModel::all();
return $this->render('@my-app/index', [
'items' => $items,
]);
}
public function create()
{
return $this->render('@my-app/form');
}
}
Views are standard Twig templates. Place them in the Views/ directory of your app:
{% extends 'cms/layout.twig' %}
{% block content %}
<h1>My App</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.created_at|date('M d, Y') }}</td>
<td><a href="/my-app/{{ item.id }}">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
Adding Migrations
If your app requires database tables, include migration files in the Migrations/ directory. Migrations are run during install and rolled back during uninstall.
<?php
// Migrations/001_create_contact_submissions_table.php
use ZephyrPHP\Database\Migration;
use ZephyrPHP\Database\Schema\Blueprint;
return new class extends Migration
{
public function up(): void
{
$this->schema->create('contact_submissions', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email');
$table->text('message');
$table->string('status')->default('new');
$table->timestamps();
});
}
public function down(): void
{
$this->schema->dropIfExists('contact_submissions');
}
};
Migration files are executed in alphabetical order. Use numeric prefixes (001, 002, 003) to control the order in which tables and columns are created.
Packaging for Distribution
When your app is ready for distribution, package it as a ZIP archive for upload to the marketplace, or publish it as a Composer package on Packagist.
ZIP Distribution
Create a ZIP file containing your entire app directory. The root of the ZIP should contain the app.json file directly (not nested inside a subdirectory).
# From your app's root directory
zip -r my-app-1.0.0.zip . -x ".git/*" "node_modules/*" "tests/*" ".env"
The resulting archive structure should look like this:
my-app-1.0.0.zip
+-- app.json
+-- App.php
+-- routes/
+-- Controllers/
+-- Views/
+-- Migrations/
+-- assets/
+-- config/
Do not include .git/, node_modules/, tests/, or any environment-specific files in your distribution archive. These increase package size and may expose sensitive information.
Composer Distribution
For apps distributed via Packagist, include a composer.json alongside your app.json:
{
"name": "myvendor/my-app",
"description": "What the app does",
"type": "zephyrphp-app",
"require": {
"php": ">=8.2",
"zephyrphp/framework": ">=0.1"
},
"autoload": {
"psr-4": {
"MyVendor\\MyApp\\": ""
}
}
}
Use the type zephyrphp-app so the framework can identify it as a marketplace app during installation.
Example: Building a Contact Form App
This walkthrough demonstrates building a complete contact form app from scratch. The app will provide a public form page, store submissions in the database, and show an admin listing in the CMS.
Step 1: Create the Manifest
{
"name": "Contact Form",
"slug": "contact-form",
"version": "1.0.0",
"main": "App",
"namespace": "Acme\\ContactForm",
"description": "A simple contact form with submission management."
}
Step 2: Create the App Class
<?php
namespace Acme\ContactForm;
use ZephyrPHP\Marketplace\MarketplaceApp;
class App extends MarketplaceApp
{
public function register(): void
{
$this->mergeConfig('contact-form', __DIR__ . '/config/contact-form.php');
}
public function boot(): void
{
$this->loadRoutes(__DIR__ . '/routes/web.php');
$this->loadViews(__DIR__ . '/Views', 'contact-form');
$this->addSidebarItem('Contact Forms', '/admin/contact-form', 'mail');
}
public function install(): void
{
$this->runMigrations(__DIR__ . '/Migrations');
}
public function uninstall(): void
{
$this->rollbackMigrations(__DIR__ . '/Migrations');
}
}
Step 3: Create the Migration
<?php
// Migrations/001_create_contact_submissions_table.php
use ZephyrPHP\Database\Migration;
use ZephyrPHP\Database\Schema\Blueprint;
return new class extends Migration
{
public function up(): void
{
$this->schema->create('contact_submissions', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email');
$table->text('message');
$table->string('status')->default('new');
$table->timestamps();
});
}
public function down(): void
{
$this->schema->dropIfExists('contact_submissions');
}
};
Step 4: Define Routes
<?php
// routes/web.php
use ZephyrPHP\Core\Routing\Route;
use Acme\ContactForm\Controllers\ContactFormController;
// Public routes
Route::get('/contact', [ContactFormController::class, 'showForm']);
Route::post('/contact', [ContactFormController::class, 'submitForm']);
// Admin routes
Route::group(['prefix' => '/admin/contact-form', 'middleware' => ['auth']], function () {
Route::get('/', [ContactFormController::class, 'index']);
Route::get('/{id}', [ContactFormController::class, 'show']);
Route::delete('/{id}', [ContactFormController::class, 'destroy']);
});
Step 5: Build the Controller
<?php
namespace Acme\ContactForm\Controllers;
use ZephyrPHP\Core\Controllers\Controller;
use ZephyrPHP\Core\Http\Request;
use Acme\ContactForm\Models\ContactSubmission;
class ContactFormController extends Controller
{
public function showForm()
{
return $this->render('@contact-form/form');
}
public function submitForm(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email',
'message' => 'required|string|max:5000',
]);
ContactSubmission::create($validated);
return redirect('/contact')->with('success', 'Thank you for your message.');
}
public function index()
{
$submissions = ContactSubmission::orderBy('created_at', 'desc')->paginate(20);
return $this->render('@contact-form/index', [
'submissions' => $submissions,
]);
}
public function show(int $id)
{
$submission = ContactSubmission::findOrFail($id);
return $this->render('@contact-form/show', [
'submission' => $submission,
]);
}
public function destroy(int $id)
{
ContactSubmission::findOrFail($id)->delete();
return redirect('/admin/contact-form')->with('success', 'Submission deleted.');
}
}
Step 6: Create the Views
Public contact form (Views/form.twig):
{% extends 'layouts/app.twig' %}
{% block content %}
<h1>Contact Us</h1>
{% if flash('success') %}
<div class="alert alert-success">{{ flash('success') }}</div>
{% endif %}
<form method="POST" action="/contact">
{{ csrf_field() }}
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
</div>
<button type="submit">Send Message</button>
</form>
{% endblock %}
Admin submissions list (Views/index.twig):
{% extends 'cms/layout.twig' %}
{% block content %}
<h1>Contact Submissions</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for submission in submissions %}
<tr>
<td>{{ submission.name }}</td>
<td>{{ submission.email }}</td>
<td>{{ submission.status }}</td>
<td>{{ submission.created_at|date('M d, Y') }}</td>
<td>
<a href="/admin/contact-form/{{ submission.id }}">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
Step 7: Package and Distribute
cd contact-form
zip -r contact-form-1.0.0.zip . -x ".git/*" "node_modules/*" "tests/*"
Upload the resulting contact-form-1.0.0.zip to the ZephyrPHP Marketplace through the developer portal, or publish it to Packagist as a Composer package.
Best Practices
- Namespace everything. Use your vendor namespace consistently to avoid class and route collisions with other apps.
- Prefix your routes. Group all routes under a prefix that matches your app slug.
- Prefix your database tables. Use a short prefix on table names (e.g.,
cf_submissions) to avoid conflicts. - Implement uninstall cleanly. Roll back migrations and remove config files so the host application is left in a clean state.
- Validate all input. Use the framework's validation system in your controllers rather than trusting raw input.
- Keep dependencies minimal. Only require packages your app truly needs. Heavy dependencies increase install time and potential conflicts.
- Document your app. Include a README with installation instructions, configuration options, and usage examples.
- Version your manifest. Follow semantic versioning so users understand the impact of updates.
Next Steps
- Learn about the Marketplace and how distribution works
- Explore Creating Themes to build visual packages
- Review Routing for advanced route configuration
- Read about Migrations for database schema management
- Understand Controllers and request handling