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)
Choosing a Slug

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']);
});
Route Prefixing

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/
Exclude Development Files

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