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)
config/config.json— Base configuration, always loaded. Contains defaults shared across all environments.config/config.{APP_ENV}.json— Environment-specific overrides. Only loaded whenAPP_ENVis set to something other thandev(e.g.,qa,pd,staging).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
| Method | Description |
|---|---|
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
| Key | Type | Default | Description |
|---|---|---|---|
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
| Key | Type | Default | Description |
|---|---|---|---|
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.
| Key | Type | Default | Description |
|---|---|---|---|
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.
| Key | Type | Default | Description |
|---|---|---|---|
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:
| Key | Type | Required | Description |
|---|---|---|---|
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.
| Key | Type | Default | Description |
|---|---|---|---|
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
| Key | Type | Default | Description |
|---|---|---|---|
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
| Key | Type | Default | Description |
|---|---|---|---|
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').