Configuration

Melodic uses a JSON-based configuration system with dot-notation access and layered environment support. Configuration files are deep-merged in a predictable order, allowing base settings to be overridden per environment.

Quick Start

$app = new Application(dirname(__DIR__));
$app->loadEnvironmentConfig();

This single call loads configuration files from the config/ directory in the correct order based on the APP_ENV environment variable.

Loading Order

config.json  →  config.{APP_ENV}.json  →  config.dev.json
  (base)         (env overrides)          (dev overrides, gitignored)
  1. config/config.json — Base configuration, always loaded. Contains defaults shared across all environments.
  2. config/config.{APP_ENV}.json — Environment-specific overrides. Only loaded when APP_ENV is set to something other than dev (e.g., qa, pd, staging).
  3. config/config.dev.json — Local developer overrides. Always loaded last if present. This file should be gitignored so developers can customize settings without affecting others.

Each file is deep-merged on top of the previous, so you only need to specify the values that differ from the base.

The APP_ENV Variable

Set the APP_ENV environment variable to select which environment file to load:

# Development (default when unset)
php -S localhost:8080 -t public

# QA
APP_ENV=qa php -S localhost:8080 -t public

# Production
APP_ENV=pd php -S localhost:8080 -t public

When APP_ENV is unset, it defaults to dev.

Checking the Environment at Runtime

The current environment is automatically set in config as app.environment:

$env = $app->getEnvironment();           // 'dev', 'qa', 'pd', etc.
$env = $app->config('app.environment');  // same thing

Configuration Files

Base Config (config/config.json)

{
    "app": {
        "debug": true
    },
    "database": {
        "dsn": "sqlite:storage/database.sqlite"
    },
    "jwt": {
        "secret": "change-me",
        "algorithm": "HS256"
    },
    "cors": {
        "allowedOrigins": ["*"],
        "allowedMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
        "allowedHeaders": ["Content-Type", "Authorization"],
        "maxAge": 3600
    }
}

Environment Override (config/config.qa.json)

Only include values that differ from the base:

{
    "database": {
        "dsn": "mysql:host=qa-db.example.com;dbname=myapp"
    },
    "jwt": {
        "secret": "qa-secret-key"
    }
}

Production Override (config/config.pd.json)

{
    "app": {
        "debug": false
    },
    "database": {
        "dsn": "mysql:host=prod-db.example.com;dbname=myapp"
    },
    "jwt": {
        "secret": "production-secret-key"
    }
}

Local Developer Override (config/config.dev.json)

This file is gitignored. Use it for local-only settings:

{
    "database": {
        "dsn": "sqlite:storage/dev.sqlite"
    }
}

Tip: Projects scaffolded with make:project include a .gitignore that excludes config/config.dev.json. Environment configs like config.qa.json and config.pd.json are tracked in version control since they contain non-secret settings (secrets should come from environment variables or a secrets manager).

Scaffolding Config Files

Generate a new environment config file with the make:config CLI command:

vendor/bin/melodic make:config staging

This creates config/config.staging.json with a minimal template. The command refuses to overwrite an existing file.

The Configuration Class

Under the hood, loadEnvironmentConfig() delegates to the Melodic\Core\Configuration class. This class is automatically registered in the DI container, so you can inject it into services and controllers.

Methods

MethodDescription
get(string $key, mixed $default = null): mixed Retrieves a value using dot-notation. Walks nested arrays by splitting the key on . separators. Returns $default if any segment is missing.
set(string $key, mixed $value): void Sets a value using dot-notation. Creates intermediate arrays as needed.
has(string $key): bool Returns true if the key exists in the configuration tree.
all(): array Returns the entire configuration as a nested associative array.
merge(array $data): void Deep-merges an array into the existing configuration. Array values are merged recursively; scalar values are overwritten.
loadFile(string $path): void Loads a JSON file from disk and merges it into the configuration. Throws RuntimeException if the file is missing or contains invalid JSON.

Dot-Notation Access

Dot-notation lets you reach into nested structures without manually traversing arrays:

// Given this JSON:
// { "database": { "dsn": "sqlite::memory:" } }

$dsn = $config->get('database.dsn');
// Returns: "sqlite::memory:"

$timeout = $config->get('database.timeout', 30);
// Returns: 30 (default, since the key does not exist)

Accessing Configuration

There are three ways to read configuration values, depending on where you are in the application.

From the Application Instance

The $app->config() method is a shortcut that delegates to the Configuration object:

// Read a single value
$dsn = $app->config('database.dsn');

// Read with a default
$debug = $app->config('app.debug', false);

// Get the entire Configuration object
$config = $app->config();

From a Service or Controller via DI

The Configuration class is registered as a singleton in the container. Inject it through your constructor:

<?php

use Melodic\Core\Configuration;

class ReportService
{
    public function __construct(
        private readonly Configuration $config,
    ) {}

    public function getReportLimit(): int
    {
        return (int) $this->config->get('reports.limit', 100);
    }
}

Inside a Service Provider

Service providers receive the container, so you can resolve Configuration directly:

public function register(Container $container): void
{
    $container->singleton(MyService::class, function (Container $c) {
        $config = $c->get(Configuration::class);
        $apiKey = $config->get('integrations.api_key');
        return new MyService($apiKey);
    });
}

Config Reference

The following tables document every configuration key recognized by the framework.

Database

KeyTypeDefaultDescription
database.dsn string PDO data source name (e.g., sqlite::memory:, mysql:host=localhost;dbname=app)
database.username string|null null Database username
database.password string|null null Database password

Authentication — General

KeyTypeDefaultDescription
auth.api.enabled bool true Enable API authentication (Bearer token validation on API routes)
auth.web.enabled bool true Enable web authentication (cookie-based sessions for browser routes)
auth.loginPath string /auth/login URL path for the login page
auth.callbackPath string /auth/callback URL path for OAuth/OIDC callback handling
auth.postLoginRedirect string / Where to redirect the user after successful login
auth.cookieName string melodic_auth Name of the authentication cookie set in the browser
auth.cookieLifetime int 3600 Cookie time-to-live in seconds

Authentication — Login Page Styling

These keys control the appearance of the built-in login page rendered by the framework. All are optional.

KeyTypeDefaultDescription
auth.loginPage.title string Sign In Page heading shown on the login form
auth.loginPage.primaryColor string #4a90d9 Primary button and accent color (CSS color value)
auth.loginPage.primaryHoverColor string #357abd Button hover color
auth.loginPage.backgroundColor string #f5f5f5 Page background color
auth.loginPage.cardBackground string #ffffff Login card/panel background color
auth.loginPage.textColor string #333333 Primary text color on the login page
auth.loginPage.subtextColor string #555555 Secondary/subtext color
auth.loginPage.logoUrl string|null null URL to a logo image displayed above the login form
auth.loginPage.logoAlt string|null null Alt text for the logo image
auth.loginPage.faviconUrl string|null null URL to a custom favicon for the login page
auth.loginPage.customCss string|null null Raw CSS string injected into the login page for additional styling

Authentication — Local JWT Signing

When using OAuth2 or local authentication providers, the framework issues its own JWT tokens signed with these settings.

KeyTypeDefaultDescription
auth.local.signingKey string Secret key used to sign and verify JWT tokens. Must be a strong, random value in production.
auth.local.issuer string melodic-app Value of the iss claim in generated tokens
auth.local.audience string melodic-app Value of the aud claim in generated tokens
auth.local.tokenLifetime int 3600 Token time-to-live in seconds
auth.local.algorithm string HS256 JWT signing algorithm (e.g., HS256, HS384, HS512)

Important: Never commit your production signingKey to version control. Use environment variables or a secrets manager, and inject the value at deploy time.

Authentication — Providers

Each entry under auth.providers defines an external (or local) authentication provider. The key is the provider name (e.g., google, github, local), and the value is an object with the following properties:

KeyTypeRequiredDescription
type string yes Provider type: oidc, oauth2, or local
label string no Human-readable button label (e.g., Sign in with Google)
discoveryUrl string OIDC only OpenID Connect discovery endpoint URL
authorizeUrl string OAuth2 only Authorization endpoint URL
tokenUrl string OAuth2 only Token exchange endpoint URL
userInfoUrl string OAuth2 only User info endpoint URL for fetching profile data
clientId string yes OAuth client ID issued by the provider
clientSecret string yes OAuth client secret
redirectUri string yes Full callback URL registered with the provider (e.g., http://localhost:8080/auth/callback/google)
audience string no Audience parameter sent with the authorization request (some providers require this)
scopes string no Space-separated list of OAuth scopes (e.g., openid profile email)
claimMap object no Maps provider-specific claim names to Melodic's standard claims. Used with OAuth2 providers to normalize user info responses.

Tip: For OIDC providers (like Google or Microsoft Entra), you only need discoveryUrl, clientId, clientSecret, redirectUri, and scopes. The framework fetches the authorize, token, and userinfo URLs automatically from the discovery document.

Authentication — Refresh Tokens

The auth.refreshToken section configures secure refresh token rotation. See Refresh Tokens for full documentation.

KeyTypeDefaultDescription
auth.refreshToken.tokenLifetime int 604800 Refresh token lifetime in seconds (default: 7 days)
auth.refreshToken.cookieName string kingdom_refresh Name of the HTTP-only cookie
auth.refreshToken.cookieDomain string "" Cookie domain (e.g. .example.com for subdomains)
auth.refreshToken.cookiePath string /auth/refresh Cookie path — scoped to the refresh endpoint
auth.refreshToken.cookieSecure bool true Require HTTPS (false for local development)
auth.refreshToken.cookieSameSite string Lax SameSite attribute (Lax, Strict, or None)

CORS

KeyTypeDefaultDescription
cors.allowedOrigins array ["*"] List of allowed origins. Use ["*"] to allow all, or specify exact origins.
cors.allowedMethods array ["GET", "POST", "PUT", "DELETE", "OPTIONS"] HTTP methods allowed in CORS preflight responses
cors.allowedHeaders array ["Content-Type", "Authorization"] Request headers the client is allowed to send
cors.maxAge int 3600 How long (in seconds) the browser can cache a preflight response

Logging

KeyTypeDefaultDescription
logging.path string|null null Directory for log files. Defaults to {basePath}/logs when not specified.
logging.level string debug Minimum log level: emergency, alert, critical, error, warning, notice, info, debug

Complete Example Config

Here is a full config/config.json showing every framework-recognized key:

{
    "app": {
        "name": "My Melodic App",
        "debug": true
    },
    "database": {
        "dsn": "mysql:host=127.0.0.1;dbname=myapp;charset=utf8mb4",
        "username": "app_user",
        "password": "secret"
    },
    "auth": {
        "api": {
            "enabled": true
        },
        "web": {
            "enabled": true
        },
        "loginPath": "/auth/login",
        "callbackPath": "/auth/callback",
        "postLoginRedirect": "/dashboard",
        "cookieName": "melodic_auth",
        "cookieLifetime": 7200,
        "loginPage": {
            "title": "Welcome Back",
            "primaryColor": "#6c63ff",
            "primaryHoverColor": "#5a52e0",
            "backgroundColor": "#0f1117",
            "cardBackground": "#1c2030",
            "textColor": "#e4e4e7",
            "subtextColor": "#8b8b9e",
            "logoUrl": "/images/logo.svg",
            "logoAlt": "My App",
            "faviconUrl": "/favicon.ico",
            "customCss": ".login-card { box-shadow: 0 4px 24px rgba(0,0,0,0.3); }"
        },
        "local": {
            "signingKey": "your-256-bit-secret-key-here",
            "issuer": "my-app",
            "audience": "my-app",
            "tokenLifetime": 3600,
            "algorithm": "HS256"
        },
        "refreshToken": {
            "tokenLifetime": 604800,
            "cookieName": "kingdom_refresh",
            "cookieDomain": "",
            "cookiePath": "/auth/refresh",
            "cookieSecure": true,
            "cookieSameSite": "Lax"
        },
        "providers": {
            "google": {
                "type": "oidc",
                "label": "Sign in with Google",
                "discoveryUrl": "https://accounts.google.com/.well-known/openid-configuration",
                "clientId": "your-google-client-id",
                "clientSecret": "your-google-client-secret",
                "redirectUri": "https://myapp.com/auth/callback/google",
                "scopes": "openid profile email"
            },
            "github": {
                "type": "oauth2",
                "label": "Sign in with GitHub",
                "authorizeUrl": "https://github.com/login/oauth/authorize",
                "tokenUrl": "https://github.com/login/oauth/access_token",
                "userInfoUrl": "https://api.github.com/user",
                "clientId": "your-github-client-id",
                "clientSecret": "your-github-client-secret",
                "redirectUri": "https://myapp.com/auth/callback/github",
                "scopes": "read:user user:email",
                "claimMap": {
                    "sub": "id",
                    "name": "name",
                    "email": "email",
                    "picture": "avatar_url"
                }
            }
        }
    },
    "cors": {
        "allowedOrigins": ["https://myapp.com", "http://localhost:3000"],
        "allowedMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
        "allowedHeaders": ["Content-Type", "Authorization"],
        "maxAge": 3600
    },
    "logging": {
        "path": "/var/log/my-app",
        "level": "info"
    }
}

Manual Config Loading

If you need more control over which files are loaded, use loadConfig() directly instead of loadEnvironmentConfig():

$app->loadConfig('config/config.json');
$app->loadConfig('config/config.qa.json');

loadConfig() accepts paths relative to the application base path or absolute paths. Each call deep-merges the new values into the existing configuration, so later files override earlier ones for the same keys.

Environment Variables for Secrets

For sensitive values like database credentials and signing keys, use environment variables and set them in config after loading:

$app->loadEnvironmentConfig();

// Override sensitive values from environment
$config = $app->config();
$config->set('database.dsn', getenv('DB_DSN') ?: $config->get('database.dsn'));
$config->set('database.username', getenv('DB_USER') ?: $config->get('database.username'));
$config->set('database.password', getenv('DB_PASS') ?: $config->get('database.password'));
$config->set('auth.local.signingKey', getenv('JWT_SECRET') ?: $config->get('auth.local.signingKey'));

Important: Never commit production secrets (database passwords, signing keys, client secrets) to version control. Use environment variables, a secrets manager, or a config.dev.json file deployed separately from your codebase.

Programmatic Overrides

You can use the Configuration::set() method to override any value at runtime, regardless of what the JSON file contains:

$app->loadEnvironmentConfig();

$config = $app->config();
$config->set('app.debug', false);
$config->set('cors.allowedOrigins', ['https://production.example.com']);

Tip: The app section (e.g., app.name, app.debug) is not consumed by the framework itself — it is a convention for your own application settings. You can add any keys you like and access them with $app->config('my.custom.key').