Skip to content

Commit 8bb8047

Browse files
[Feat] Per-site PHP runtime settings (#1180)
* php settings * fixes * documentation * doc tweak
1 parent c60a20a commit 8bb8047

16 files changed

Lines changed: 686 additions & 9 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace App\Actions\Site;
4+
5+
use App\Exceptions\SSHError;
6+
use App\Models\Site;
7+
use Illuminate\Support\Facades\Validator;
8+
9+
class UpdatePHPSettings
10+
{
11+
/**
12+
* @param array<string, mixed> $input
13+
*
14+
* @throws SSHError
15+
*/
16+
public function update(Site $site, array $input): void
17+
{
18+
$validated = $this->validate($input);
19+
20+
$typeData = $site->type_data ?? [];
21+
$typeData['php'] = $validated;
22+
$site->update(['type_data' => $typeData]);
23+
24+
$site->webserver()->updateVHost($site);
25+
26+
app(BroadcastSiteUpdate::class)->broadcast($site);
27+
}
28+
29+
/**
30+
* @param array<string, mixed> $input
31+
* @return array{max_upload_size: int|null, max_execution_time: int|null, memory_limit: int|null, max_input_vars: int|null}
32+
*/
33+
private function validate(array $input): array
34+
{
35+
$validator = Validator::make($input, [
36+
'max_upload_size' => ['nullable', 'integer', 'min:1', 'max:10240'],
37+
'max_execution_time' => ['nullable', 'integer', 'min:1', 'max:3600'],
38+
'memory_limit' => ['nullable', 'integer', 'min:16', 'max:8192'],
39+
'max_input_vars' => ['nullable', 'integer', 'min:100', 'max:100000'],
40+
]);
41+
42+
$validator->after(function ($validator) use ($input): void {
43+
if ($validator->errors()->hasAny(['max_upload_size', 'memory_limit'])) {
44+
return;
45+
}
46+
47+
$upload = $input['max_upload_size'] ?? null;
48+
$memory = $input['memory_limit'] ?? null;
49+
50+
if (is_numeric($upload) && is_numeric($memory) && (int) $memory < (int) $upload) {
51+
$validator->errors()->add(
52+
'memory_limit',
53+
'The memory limit must be greater than or equal to the max upload size.'
54+
);
55+
}
56+
});
57+
58+
$validated = $validator->validate();
59+
60+
return [
61+
'max_upload_size' => $this->intOrNull($validated['max_upload_size'] ?? null),
62+
'max_execution_time' => $this->intOrNull($validated['max_execution_time'] ?? null),
63+
'memory_limit' => $this->intOrNull($validated['memory_limit'] ?? null),
64+
'max_input_vars' => $this->intOrNull($validated['max_input_vars'] ?? null),
65+
];
66+
}
67+
68+
private function intOrNull(mixed $value): ?int
69+
{
70+
return is_numeric($value) ? (int) $value : null;
71+
}
72+
}

app/Actions/Webserver/AbstractGenerateConfig.php

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
abstract class AbstractGenerateConfig
1414
{
15+
protected const PHP_VALUE_TOKEN = '@@VITO_PHP_VALUE@@';
16+
1517
/**
1618
* Generate the full vhost config for a site.
1719
*/
@@ -26,7 +28,9 @@ public function generate(Site $site, ?string $template = null): string
2628
'escape' => fn ($value) => $value,
2729
]);
2830

29-
return format_webserver_config($engine->render($template, $data));
31+
$rendered = format_webserver_config($engine->render($template, $data));
32+
33+
return str_replace(self::PHP_VALUE_TOKEN, $data['php_value_string'], $rendered);
3034
}
3135

3236
/**
@@ -197,6 +201,9 @@ protected function buildCommonData(Site $site, string $primaryDomain): array
197201
$isOctane = (bool) data_get($site->type_data, 'octane', false);
198202
$isPhp = ($siteTypeData['is_php'] ?? false) && ! $isOctane;
199203

204+
$phpEnabled = $isPhp && $site->vhost_template === null;
205+
$phpValueString = $phpEnabled ? $this->phpValueDirectives($site) : '';
206+
200207
$basicAuth = data_get($site->type_data, 'basic_auth', []);
201208
$basicAuthEnabled = ! empty($basicAuth['enabled']) && ! empty($basicAuth['users']);
202209

@@ -211,6 +218,10 @@ protected function buildCommonData(Site $site, string $primaryDomain): array
211218
'is_octane' => $isOctane,
212219
'octane_port' => data_get($site->type_data, 'octane_port', 8000),
213220
'php_socket' => $isPhp ? $this->buildPhpSocket($site) : '',
221+
'php_value' => $phpValueString !== '',
222+
'php_value_string' => $phpValueString,
223+
'php_max_upload_size' => $phpEnabled ? $this->phpSetting($site, 'max_upload_size') : null,
224+
'php_max_execution_time' => $phpEnabled ? $this->phpSetting($site, 'max_execution_time') : null,
214225
'port' => $site->port,
215226
'redirects' => $this->buildRedirects($site),
216227
'type_data' => $site->type_data ?? [],
@@ -240,6 +251,7 @@ protected function enrichServerBlocks(array $blocks, array $data): array
240251
$block['is_octane'] = $data['is_octane'];
241252
$block['octane_port'] = $data['octane_port'];
242253
$block['php_socket'] = $data['php_socket'];
254+
$block['php_value'] = $data['php_value'];
243255
$block['port'] = $data['port'];
244256
$block['redirects'] = $data['redirects'];
245257
$block['basic_auth_enabled'] = $data['basic_auth_enabled'];
@@ -266,4 +278,44 @@ protected function buildRedirects(Site $site): array
266278

267279
return $redirects;
268280
}
281+
282+
/**
283+
* Build the multi-line PHP_VALUE directive string from the site's
284+
* per-site PHP settings (type_data['php']). Empty when nothing is set.
285+
*/
286+
protected function phpValueDirectives(Site $site): string
287+
{
288+
$directives = [];
289+
290+
$upload = $this->phpSetting($site, 'max_upload_size');
291+
if ($upload !== null) {
292+
$directives[] = "upload_max_filesize={$upload}M";
293+
$directives[] = "post_max_size={$upload}M";
294+
}
295+
296+
$execution = $this->phpSetting($site, 'max_execution_time');
297+
if ($execution !== null) {
298+
$directives[] = "max_execution_time={$execution}";
299+
$directives[] = "max_input_time={$execution}";
300+
}
301+
302+
$memory = $this->phpSetting($site, 'memory_limit');
303+
if ($memory !== null) {
304+
$directives[] = "memory_limit={$memory}M";
305+
}
306+
307+
$inputVars = $this->phpSetting($site, 'max_input_vars');
308+
if ($inputVars !== null) {
309+
$directives[] = "max_input_vars={$inputVars}";
310+
}
311+
312+
return implode("\n", $directives);
313+
}
314+
315+
protected function phpSetting(Site $site, string $key): ?int
316+
{
317+
$value = data_get($site->type_data, "php.{$key}");
318+
319+
return is_numeric($value) ? (int) $value : null;
320+
}
269321
}

app/Actions/Webserver/GenerateCaddyConfig.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ protected function enrichServerBlock(array $block, array $data): array
8080
{
8181
$block['lb_servers'] = $data['lb_servers'];
8282
$block['lb_policy'] = $data['lb_policy'];
83+
$block['request_body_max_size'] = $data['php_max_upload_size'] !== null
84+
? $data['php_max_upload_size'].'MB'
85+
: false;
8386

8487
return $block;
8588
}

app/Actions/Webserver/GenerateNginxConfig.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ protected function transformDomains(array $domains, bool $httpOnly): array
8484
protected function enrichServerBlock(array $block, array $data): array
8585
{
8686
$block['upstream_name'] = $data['upstream_name'];
87+
$block['client_max_body_size'] = $data['php_max_upload_size'] !== null
88+
? $data['php_max_upload_size'].'M'
89+
: false;
90+
$block['fastcgi_read_timeout'] = $data['php_max_execution_time'] !== null
91+
? $data['php_max_execution_time'].'s'
92+
: false;
8793

8894
return $block;
8995
}

app/Http/Controllers/SiteSettingController.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Actions\Site\PreviewVhost;
77
use App\Actions\Site\UpdateBasicAuth;
88
use App\Actions\Site\UpdateBranch;
9+
use App\Actions\Site\UpdatePHPSettings;
910
use App\Actions\Site\UpdatePHPVersion;
1011
use App\Actions\Site\UpdatePort;
1112
use App\Actions\Site\UpdateSiteStats;
@@ -88,6 +89,21 @@ public function updatePHPVersion(Request $request, Server $server, Site $site):
8889
return back()->with('success', 'PHP version updated successfully.');
8990
}
9091

92+
/**
93+
* @throws SSHError
94+
*/
95+
#[Patch('/php-settings', name: 'site-settings.update-php-settings')]
96+
public function updatePHPSettings(Request $request, Server $server, Site $site): RedirectResponse
97+
{
98+
$this->authorize('update', [$site, $server]);
99+
100+
abort_unless($site->supportsPhpSettings(), 404);
101+
102+
app(UpdatePHPSettings::class)->update($site, $request->input());
103+
104+
return back()->with('success', 'PHP settings updated successfully.');
105+
}
106+
91107
/**
92108
* @throws SSHError
93109
*/

app/Http/Resources/SiteResource.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ public function toArray(Request $request): array
3535
'webserver_creates_site_ssls' => $this->webserver()->createsSiteSSLs(),
3636
'path' => $this->path,
3737
'php_version' => $this->php_version,
38+
'php_settings' => $this->phpSettings(),
39+
'supports_php_settings' => $this->supportsPhpSettings(),
3840
'repository' => $this->repository,
3941
'branch' => $this->branch,
4042
'status' => $this->status->getText(),
@@ -78,6 +80,8 @@ private function sanitisedTypeData(): array
7880
{
7981
$typeData = $this->type_data ?? [];
8082

83+
unset($typeData['php']);
84+
8185
if (isset($typeData['basic_auth']['users']) && is_array($typeData['basic_auth']['users'])) {
8286
$typeData['basic_auth']['users'] = array_map(
8387
fn (array $u) => ['username' => $u['username'] ?? ''],

app/Models/Site.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ public function getWarnings(): array
204204
$warnings[] = ['key' => 'vhost_generation_disabled'];
205205
}
206206

207+
if ($this->vhost_template !== null
208+
&& array_filter($this->phpSettings(), fn ($v) => $v !== null) !== []) {
209+
$warnings[] = ['key' => 'php_settings_ignored'];
210+
}
211+
207212
$expiring = $hostedDomains->filter(
208213
fn ($hd) => $hd->ssl_id
209214
&& $hd->relationLoaded('ssl')
@@ -506,6 +511,41 @@ public function php(): ?Service
506511
return null;
507512
}
508513

514+
public function supportsPhpSettings(): bool
515+
{
516+
if (! $this->php_version) {
517+
return false;
518+
}
519+
520+
$isPhp = (bool) ($this->type()->vhostData()['is_php'] ?? false);
521+
$isOctane = (bool) data_get($this->type_data, 'octane', false);
522+
523+
return $isPhp
524+
&& ! $isOctane
525+
&& $this->vhost_generation_enabled
526+
&& $this->vhost_template === null;
527+
}
528+
529+
/**
530+
* @return array{max_upload_size: int|null, max_execution_time: int|null, memory_limit: int|null, max_input_vars: int|null}
531+
*/
532+
public function phpSettings(): array
533+
{
534+
return [
535+
'max_upload_size' => $this->phpSetting('max_upload_size'),
536+
'max_execution_time' => $this->phpSetting('max_execution_time'),
537+
'memory_limit' => $this->phpSetting('memory_limit'),
538+
'max_input_vars' => $this->phpSetting('max_input_vars'),
539+
];
540+
}
541+
542+
private function phpSetting(string $key): ?int
543+
{
544+
$value = data_get($this->type_data, "php.{$key}");
545+
546+
return is_numeric($value) ? (int) $value : null;
547+
}
548+
509549
public function getUrl(): string
510550
{
511551
if ($this->ssl_enabled) {

docs/4.x/sites/settings.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
## Introduction
44

5-
In the Settings page you can manage your site's details, its PHP version, web directory, vhost
6-
template, basic auth, and more.
5+
In the Settings page you can manage your site's details, its PHP version and PHP settings, web
6+
directory, vhost template, basic auth, and more.
77

88
## Site details
99

@@ -18,6 +18,36 @@ You can change the PHP version of each website in their Settings page.
1818
Make sure that the PHP version you want to use is already installed in the [PHP](../servers/php#install-and-uninstall)
1919
page.
2020

21+
## Configure PHP Settings
22+
23+
For PHP sites you can tune common per-site PHP runtime limits without editing any files. Click
24+
**Configure** next to the PHP version on the Settings page to set:
25+
26+
- **Max upload size** — the largest file that can be uploaded (MB).
27+
- **Max execution time** — how long a request may run (seconds).
28+
- **Memory limit** — the maximum memory a request may use (MB).
29+
- **Max input vars** — the maximum number of input variables, e.g. form fields.
30+
31+
Leave a field empty to use the server's default. Vito applies these to the site's vhost (via FastCGI
32+
`PHP_VALUE`, together with `client_max_body_size`/`fastcgi_read_timeout` on nginx and `request_body`
33+
on Caddy), so they take effect for requests served through nginx/Caddy and are preserved across
34+
deployments. They do not affect the PHP CLI.
35+
36+
Where a value is left unset, the server default applies. PHP defaults to 2 MB uploads, a 30s
37+
execution time, a 128 MB memory limit, and 1000 input vars; on nginx, requests are additionally
38+
capped at 1 MB (body size) and 60s until you raise them here.
39+
40+
:::tip
41+
These settings are per-site. PHP-FPM process pools are shared per system user, so process-manager
42+
tuning (such as `pm.max_children`) is not configured here.
43+
:::
44+
45+
:::warning
46+
Configuration is available only for PHP sites that use automatic vhost generation and the default
47+
vhost template. If you use a custom vhost template, add the directives to your template manually — a
48+
banner warns you when stored PHP settings cannot be applied.
49+
:::
50+
2151
## Change branch
2252

2353
You can change the branch of your cloned repository in the Settings page.

resources/js/components/dialogs/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import RestoreBackup from '@/pages/backups/components/restore-backup';
3131
import SiteFeatureAction from '@/pages/site-features/components/feature-action';
3232
import ServerFeatureAction from '@/pages/server-features/components/feature-action';
3333
import Fail2banForm from '@/pages/security/components/fail2ban-form';
34+
import PhpSettingsDialog from '@/pages/site-settings/components/php-settings-dialog';
3435

3536
export type DialogControlProps = { open: boolean; onOpenChange: (open: boolean) => void };
3637

@@ -79,6 +80,7 @@ export const dialogs = {
7980
siteFeatureAction: SiteFeatureAction,
8081
serverFeatureAction: ServerFeatureAction,
8182
fail2banForm: Fail2banForm,
83+
phpSettings: PhpSettingsDialog,
8284
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8385
} as const satisfies Record<string, ComponentType<any>>;
8486

resources/js/components/site-banners.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export default function SiteBanners({ site }: { site: Site }) {
107107
const pendingDomainsWarning = warnings.find((w) => w.key === 'pending_domains');
108108
const sslDisabledWarning = warnings.find((w) => w.key === 'ssl_disabled');
109109
const vhostWarning = warnings.find((w) => w.key === 'vhost_generation_disabled');
110+
const phpSettingsIgnoredWarning = warnings.find((w) => w.key === 'php_settings_ignored');
110111
const sslExpiringWarning = warnings.find((w) => w.key === 'ssl_expiring');
111112
const needsFirstDeployWarning = warnings.find((w) => w.key === 'needs_first_deploy');
112113
const workerWarnings = warnings.filter((w): w is Extract<SiteWarning, { key: 'worker_not_running' }> => w.key === 'worker_not_running');
@@ -205,6 +206,26 @@ export default function SiteBanners({ site }: { site: Site }) {
205206
});
206207
}
207208

209+
if (phpSettingsIgnoredWarning) {
210+
items.push({
211+
key: 'php-settings-ignored',
212+
title: 'PHP settings are not applied',
213+
description: (
214+
<>
215+
This site has custom PHP settings, but a custom VHost template is in use so they are not written to the server. Add the directives to your
216+
template manually, or reset the template to apply them automatically.
217+
</>
218+
),
219+
action: (
220+
<Link href={route('site-settings', { server: site.server_id, site: site.id })}>
221+
<Button variant="outline" size="sm">
222+
Go to Settings
223+
</Button>
224+
</Link>
225+
),
226+
});
227+
}
228+
208229
if (needsFirstDeployWarning) {
209230
items.push({
210231
key: 'needs-first-deploy',

0 commit comments

Comments
 (0)