• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nette / forms / 28608035798

02 Jul 2026 04:53PM UTC coverage: 93.28% (-0.2%) from 93.512%
28608035798

push

github

dg
Form: submission detection redesigned around SubmissionSource objects

A form no longer inherits its way of receiving submitted data, it is
given a source:

- interface SubmissionSource { receiveData(Form): ?array; prepare(Form) }
- Sources\HttpSource - reads the form's own HTTP request: method match,
  Sec-Fetch origin policy (?FetchSite $allowedOrigin, null disables the
  check), POST+FILES/GET merge and the '_form_' tracker of a named form,
  which it installs itself in the prepare() render hook and validates
  against the form name at receive time
- Sources\NullSource - never submitted; used by Repeater item templates
  and Blueprint dummy forms instead of the former anonymous-class trick,
  so introspective code gets false from isSubmitted() instead of hacks

Form gains setSubmissionSource() and a protected createDefaultSource()
seam: the default HttpSource materializes lazily at the first submission
query or render, so a bare `new Form` keeps its behavior. Descendants
anchored elsewhere (Nette\Application\UI\Form) return null from
createDefaultSource() and set their source later; the write-once setter
runs a catch-up loadHttpData() pass so that controls attached before the
source load their values. The presenter signal field of UI\Form is the
same kind of marker as the tracker and moves to the prepare() hook of
its source on the application side.

Behavior notes: the tracker is installed at render time, so it renders
after user fields and $form[TrackerId] exists only after
fireRenderEvents(); Form::$httpRequest was replaced by injecting
`new HttpSource($request)`; the cross-origin flag lives in the source,
allowCrossOrigin() delegates to it.

284 of 326 new or added lines in 5 files covered. (87.12%)

58 existing lines in 9 files now uncovered.

2443 of 2619 relevant lines covered (93.28%)

0.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.83
/src/Forms/Controls/BaseControl.php
1
<?php declare(strict_types=1);
1✔
2

3
/**
4
 * This file is part of the Nette Framework (https://nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Nette\Forms\Controls;
9

10
use Nette;
11
use Nette\Forms\Control;
12
use Nette\Forms\Form;
13
use Nette\Forms\Rules;
14
use Nette\Utils\Html;
15
use Stringable;
16
use function array_shift, array_unique, explode, get_parent_class, implode, is_array, is_string, sprintf, str_contains, str_ends_with, str_replace, substr;
17

18

19
/**
20
 * Base implementation for form controls with HTML rendering, validation, translation, and option support.
21
 *
22
 * @property-read string $name
23
 * @property-read Form $form
24
 * @property-deprecated string $htmlName
25
 * @property   string|bool|null $htmlId
26
 * @property   mixed $value
27
 * @property-deprecated   string|Stringable $caption
28
 * @property   bool $disabled
29
 * @property-deprecated   bool $omitted
30
 * @property-read Html $control
31
 * @property-read Html $label
32
 * @property-read Html $controlPrototype
33
 * @property-read Html $labelPrototype
34
 * @property   bool $required
35
 * @property-deprecated bool $filled
36
 * @property-read string[] $errors
37
 * @property-deprecated array<string,mixed> $options
38
 * @property-read string $error
39
 */
40
abstract class BaseControl extends Nette\ComponentModel\Component implements Control
41
{
42
        use Nette\SmartObject;
43

44
        public static string $idMask = 'frm-%s';
45

46
        protected mixed $value = null;
47
        protected Html $control;
48
        protected Html $label;
49
        protected bool $disabled = false;
50

51
        /** @var array<string, array<class-string, callable(static): mixed>> */
52
        private static array $extMethods = [];
53
        private string|Stringable|null $caption;
54

55
        /** @var list<string|Stringable> */
56
        private array $errors = [];
57
        private ?bool $omitted = null;
58
        private Rules $rules;
59

60
        /** true means autodetect */
61
        private Nette\Localization\Translator|true|null $translator = true;
62

63
        /** @var array<string, mixed> */
64
        private array $options = [];
65

66

67
        public function __construct(string|Stringable|null $caption = null)
1✔
68
        {
69
                $this->control = Html::el('input', ['type' => null, 'name' => null]);
1✔
70
                $this->label = Html::el('label');
1✔
71
                $this->caption = $caption;
1✔
72
                $this->rules = new Rules($this);
1✔
73
                $this->setValue(null);
1✔
74
                $this->monitor(Form::class, function (Form $form): void {
1✔
75
                        if (!$this->isDisabled() && $form->isAnchored() && $form->isSubmitted()) {
1✔
76
                                $this->loadHttpData();
1✔
77
                        }
78
                });
1✔
79
        }
1✔
80

81

82
        public function setCaption(string|Stringable|null $caption): static
83
        {
UNCOV
84
                $this->caption = $caption;
×
85
                return $this;
×
86
        }
87

88

89
        public function getCaption(): string|Stringable|null
90
        {
91
                return $this->caption;
1✔
92
        }
93

94

95
        /**
96
         * Returns form.
97
         * @return ($throw is true ? Form : ?Form)
98
         */
99
        public function getForm(bool $throw = true): ?Form
1✔
100
        {
101
                return $this->lookup(Form::class, $throw);
1✔
102
        }
103

104

105
        /**
106
         * Loads HTTP data.
107
         */
108
        public function loadHttpData(): void
109
        {
110
                $this->setValue($this->getSubmittedValue(Form::DataText));
1✔
111
        }
1✔
112

113

114
        /**
115
         * Returns the submitted HTTP value for this control, or one of its sub-values when $key is specified.
116
         */
117
        protected function getSubmittedValue(int $type, ?string $key = null): mixed
1✔
118
        {
119
                $htmlName = $this->control->name;
1✔
120
                if (is_string($htmlName)) {
1✔
121
                        // overridden HTML name attribute is an absolute pointer into the raw submitted data, bypassing the component hierarchy
122
                        $path = explode('[', str_replace(['[]', ']', '.'], ['', '', '_'], $htmlName . ($key === null ? '' : "[$key]")));
1✔
123
                        $value = Nette\Utils\Arrays::get($this->getForm()->getSubmittedData(), $path, null);
1✔
124
                        return Nette\Forms\Helpers::sanitizeData($type, $value);
1✔
125
                }
126

127
                $parent = $this->getParent() ?? throw new Nette\InvalidStateException('Control is not attached to any form.');
1✔
128
                assert($parent instanceof Nette\Forms\Container);
129
                $name = $this->getName();
1✔
130
                assert($name !== null);
131
                if ($parent instanceof Form) {
1✔
132
                        $name = Nette\Forms\Helpers::sanitizeHtmlName($name); // mirrors generateHtmlName() which sanitizes only top-level names
1✔
133
                }
134

135
                $value = $parent->getSubmittedData()[$name] ?? null;
1✔
136
                if ($key !== null) {
1✔
137
                        $value = is_array($value) ? ($value[$key] ?? null) : null;
1✔
138
                }
139

140
                return Nette\Forms\Helpers::sanitizeData($type, $value);
1✔
141
        }
142

143

144
        /**
145
         * Returns the submitted HTTP value for this control.
146
         * @deprecated use getSubmittedValue()
147
         */
148
        #[\Deprecated('use getSubmittedValue()')]
149
        protected function getHttpData(int $type, ?string $htmlTail = null): mixed
1✔
150
        {
151
                trigger_error(__METHOD__ . '() is deprecated, use getSubmittedValue()', E_USER_DEPRECATED);
1✔
152
                if ($htmlTail === null || $htmlTail === '') {
1✔
UNCOV
153
                        return $this->getSubmittedValue($type);
×
154
                }
155

156
                if (str_ends_with($htmlTail, '[]')) {
1✔
157
                        $type |= Form::DataList;
1✔
158
                        $htmlTail = substr($htmlTail, 0, -2);
1✔
159
                }
160

161
                $keys = explode('[', str_replace([']', '.'], ['', '_'], $htmlTail));
1✔
162
                if ($keys[0] === '') {
1✔
163
                        array_shift($keys);
1✔
164
                }
165

166
                $data = $this->getSubmittedValue(Form::DataArray);
1✔
167
                assert(is_array($data));
168
                return Nette\Forms\Helpers::sanitizeData($type, Nette\Utils\Arrays::get($data, $keys, null));
1✔
169
        }
170

171

172
        /**
173
         * Returns HTML name of control. When the 'name' attribute is overridden, it is used
174
         * both for rendering and as an absolute key for reading submitted data.
175
         */
176
        public function getHtmlName(): string
177
        {
178
                return $this->control->name ?? Nette\Forms\Helpers::generateHtmlName($this->lookupPath(Form::class));
1✔
179
        }
180

181

182
        /********************* interface Control ****************d*g**/
183

184

185
        /** @internal */
186
        public function setValue(mixed $value): static
1✔
187
        {
188
                $this->value = $value;
1✔
189
                return $this;
1✔
190
        }
191

192

193
        public function getValue(): mixed
194
        {
195
                return $this->value;
1✔
196
        }
197

198

199
        /**
200
         * Is control filled?
201
         */
202
        public function isFilled(): bool
203
        {
204
                $value = $this->getValue();
1✔
205
                return $value !== null && $value !== [] && $value !== '';
1✔
206
        }
207

208

209
        /**
210
         * Sets the default value. Has no effect on submitted or disabled controls.
211
         */
212
        public function setDefaultValue(mixed $value): static
1✔
213
        {
214
                $form = $this->getForm(throw: false);
1✔
215
                if ($this->isDisabled() || !$form || !$form->isAnchored() || !$form->isSubmitted()) {
1✔
216
                        $this->setValue($value);
1✔
217
                }
218

219
                return $this;
1✔
220
        }
221

222

223
        /**
224
         * Disables or enables control.
225
         */
226
        public function setDisabled(bool $state = true): static
1✔
227
        {
228
                $this->disabled = $state;
1✔
229
                if ($state) {
1✔
230
                        $this->setValue(null);
1✔
231
                } elseif (($form = $this->getForm(throw: false)) && $form->isAnchored() && $form->isSubmitted()) {
1✔
232
                        $this->loadHttpData();
1✔
233
                }
234

235
                return $this;
1✔
236
        }
237

238

239
        /**
240
         * Is control disabled?
241
         */
242
        public function isDisabled(): bool
243
        {
244
                return $this->disabled;
1✔
245
        }
246

247

248
        /**
249
         * Excludes or includes the control value from $form->getValues() result.
250
         */
251
        public function setOmitted(bool $state = true): static
1✔
252
        {
253
                $this->omitted = $state;
1✔
254
                return $this;
1✔
255
        }
256

257

258
        /**
259
         * Is control value excluded from $form->getValues() result?
260
         */
261
        public function isOmitted(): bool
262
        {
263
                return $this->omitted || ($this->isDisabled() && $this->omitted === null);
1✔
264
        }
265

266

267
        /********************* rendering ****************d*g**/
268

269

270
        /**
271
         * Generates control's HTML element.
272
         */
273
        public function getControl(): Html|string
274
        {
275
                $this->setOption('rendered', true);
1✔
276
                $el = clone $this->control;
1✔
277
                return $el->addAttributes([
1✔
278
                        'name' => $this->getHtmlName(),
1✔
279
                        'id' => $this->getHtmlId(),
1✔
280
                        'required' => $this->isRequired(),
1✔
281
                        'disabled' => $this->isDisabled(),
1✔
282
                        'data-nette-rules' => Nette\Forms\Helpers::exportRules($this->rules) ?: null,
1✔
283
                ]);
284
        }
285

286

287
        /**
288
         * Generates label's HTML element.
289
         */
290
        public function getLabel(string|Stringable|null $caption = null): Html|string|null
1✔
291
        {
292
                $label = clone $this->label;
1✔
293
                $label->for = $this->getHtmlId();
1✔
294
                $caption ??= $this->caption;
1✔
295
                $translator = $this->getForm()->getTranslator();
1✔
296
                $label->setText($translator && $caption !== null && !$caption instanceof Nette\HtmlStringable ? $translator->translate($caption) : $caption);
1✔
297
                return $label;
1✔
298
        }
299

300

301
        public function getControlPart(): ?Html
302
        {
303
                $control = $this->getControl();
1✔
304
                return $control instanceof Html ? $control : null;
1✔
305
        }
306

307

308
        public function getLabelPart(): ?Html
309
        {
310
                $label = $this->getLabel();
1✔
311
                return $label instanceof Html ? $label : null;
1✔
312
        }
313

314

315
        /**
316
         * Returns control's HTML element template.
317
         */
318
        public function getControlPrototype(): Html
319
        {
320
                return $this->control;
1✔
321
        }
322

323

324
        /**
325
         * Returns label's HTML element template.
326
         */
327
        public function getLabelPrototype(): Html
328
        {
UNCOV
329
                return $this->label;
×
330
        }
331

332

333
        /**
334
         * Changes control's HTML id.
335
         */
336
        public function setHtmlId(string|bool|null $id): static
1✔
337
        {
338
                $this->control->id = $id;
1✔
339
                return $this;
1✔
340
        }
341

342

343
        /**
344
         * Returns control's HTML id.
345
         */
346
        public function getHtmlId(): string|bool|null
347
        {
348
                if (!isset($this->control->id)) {
1✔
349
                        $form = $this->getForm();
1✔
350
                        $prefix = $form instanceof Nette\Application\UI\Form || $form->getName() === null
1✔
351
                                ? ''
1✔
352
                                : $form->getName() . '-';
1✔
353
                        $this->control->id = sprintf(self::$idMask, $prefix . $this->lookupPath());
1✔
354
                }
355

356
                return $this->control->id;
1✔
357
        }
358

359

360
        /**
361
         * Changes control's HTML attribute.
362
         */
363
        public function setHtmlAttribute(string $name, mixed $value = true): static
1✔
364
        {
365
                $this->control->$name = $value;
1✔
366
                if (
367
                        $name === 'name'
1✔
368
                        && ($form = $this->getForm(throw: false))
1✔
369
                        && !$this->isDisabled()
1✔
370
                        && $form->isAnchored()
1✔
371
                        && $form->isSubmitted()
1✔
372
                ) {
373
                        $this->loadHttpData();
1✔
374
                }
375

376
                return $this;
1✔
377
        }
378

379

380
        #[\Deprecated('use setHtmlAttribute()')]
381
        public function setAttribute(string $name, mixed $value = true): static
382
        {
UNCOV
383
                trigger_error(__METHOD__ . '() was renamed to setHtmlAttribute()', E_USER_DEPRECATED);
×
UNCOV
384
                return $this->setHtmlAttribute($name, $value);
×
385
        }
386

387

388
        /********************* translator ****************d*g**/
389

390

391
        public function setTranslator(?Nette\Localization\Translator $translator): static
1✔
392
        {
393
                $this->translator = $translator;
1✔
394
                return $this;
1✔
395
        }
396

397

398
        /**
399
         * Returns the translator, or inherits it from the form when not explicitly set.
400
         */
401
        public function getTranslator(): ?Nette\Localization\Translator
402
        {
403
                if ($this->translator === true) {
1✔
404
                        return $this->getForm(throw: false)
1✔
405
                                ? $this->getForm()->getTranslator()
1✔
406
                                : null;
1✔
407
                }
408

409
                return $this->translator ?: null;
1✔
410
        }
411

412

413
        /**
414
         * Translates a string or array of strings using the configured translator, or returns the value unchanged if no translator is set or the value is HtmlStringable.
415
         */
416
        public function translate(mixed $value, mixed ...$parameters): mixed
1✔
417
        {
418
                if ($translator = $this->getTranslator()) {
1✔
419
                        $tmp = is_array($value) ? [&$value] : [[&$value]];
1✔
420
                        foreach ($tmp[0] as &$v) {
1✔
421
                                if ($v != null && !$v instanceof Nette\HtmlStringable) { // intentionally ==
1✔
422
                                        $v = $translator->translate($v, ...$parameters);
1✔
423
                                }
424
                        }
425
                }
426

427
                return $value;
1✔
428
        }
429

430

431
        /********************* rules ****************d*g**/
432

433

434
        /**
435
         * Adds a validation rule.
436
         * @param  (callable(Control, mixed): bool)|string  $validator
437
         */
438
        public function addRule(
1✔
439
                callable|string $validator,
440
                string|Stringable|null $errorMessage = null,
441
                mixed $arg = null,
442
        ): static
443
        {
444
                $this->rules->addRule($validator, $errorMessage, $arg);
1✔
445
                return $this;
1✔
446
        }
447

448

449
        /**
450
         * Adds a validation condition and returns a new branch.
451
         * @param  (callable(Control, mixed): bool)|string|bool  $validator
452
         */
453
        public function addCondition(callable|string|bool $validator, mixed $value = null): Rules
1✔
454
        {
455
                return $this->rules->addCondition($validator, $value);
1✔
456
        }
457

458

459
        /**
460
         * Adds a validation condition based on another control and returns a new branch.
461
         * @param  (callable(Control, mixed): bool)|string  $validator
462
         */
463
        public function addConditionOn(Control $control, callable|string $validator, mixed $value = null): Rules
1✔
464
        {
465
                return $this->rules->addConditionOn($control, $validator, $value);
1✔
466
        }
467

468

469
        /**
470
         * Adds an input filter callback.
471
         * @param callable(mixed): mixed  $filter
472
         */
473
        public function addFilter(callable $filter): static
1✔
474
        {
475
                $this->getRules()->addFilter($filter);
1✔
476
                return $this;
1✔
477
        }
478

479

480
        public function getRules(): Rules
481
        {
482
                return $this->rules;
1✔
483
        }
484

485

486
        /**
487
         * Makes control mandatory.
488
         */
489
        public function setRequired(string|Stringable|bool $value = true): static
1✔
490
        {
491
                $this->rules->setRequired($value);
1✔
492
                return $this;
1✔
493
        }
494

495

496
        /**
497
         * Is control mandatory?
498
         */
499
        public function isRequired(): bool
500
        {
501
                return $this->rules->isRequired();
1✔
502
        }
503

504

505
        /**
506
         * Performs server-side validation against all rules.
507
         */
508
        public function validate(): void
509
        {
510
                if ($this->isDisabled()) {
1✔
511
                        return;
1✔
512
                }
513

514
                $this->cleanErrors();
1✔
515
                $this->rules->validate();
1✔
516
        }
1✔
517

518

519
        /**
520
         * Adds error message to the list.
521
         */
522
        public function addError(string|Stringable $message, bool $translate = true): void
1✔
523
        {
524
                $this->errors[] = $translate ? $this->translate($message) : $message;
1✔
525
        }
1✔
526

527

528
        /**
529
         * Returns all control errors joined into one string, or null if there are no errors.
530
         */
531
        public function getError(): ?string
532
        {
533
                return $this->errors ? implode(' ', array_unique($this->errors)) : null;
1✔
534
        }
535

536

537
        /**
538
         * Returns all unique validation errors for this control.
539
         * @return list<string|Stringable>
540
         */
541
        public function getErrors(): array
542
        {
543
                return array_values(array_unique($this->errors));
1✔
544
        }
545

546

547
        public function hasErrors(): bool
548
        {
549
                return (bool) $this->errors;
1✔
550
        }
551

552

553
        public function cleanErrors(): void
554
        {
555
                $this->errors = [];
1✔
556
        }
1✔
557

558

559
        /********************* user data ****************d*g**/
560

561

562
        /**
563
         * Sets a rendering or user-specific option (e.g. 'description', 'class', 'id').
564
         */
565
        public function setOption(string $key, mixed $value): static
1✔
566
        {
567
                if ($value === null) {
1✔
UNCOV
568
                        unset($this->options[$key]);
×
569
                } else {
570
                        $this->options[$key] = $value;
1✔
571
                }
572

573
                return $this;
1✔
574
        }
575

576

577
        /**
578
         * Returns a rendering or user-specific option value.
579
         */
580
        public function getOption(string $key): mixed
1✔
581
        {
582
                return $this->options[$key] ?? null;
1✔
583
        }
584

585

586
        /**
587
         * Returns all rendering and user-specific options.
588
         * @return array<string, mixed>
589
         */
590
        public function getOptions(): array
591
        {
UNCOV
592
                return $this->options;
×
593
        }
594

595

596
        /********************* extension methods ****************d*g**/
597

598

599
        /** @param  mixed[]  $args */
600
        public function __call(string $name, array $args): mixed
1✔
601
        {
602
                $class = static::class;
1✔
603
                do {
604
                        if (isset(self::$extMethods[$name][$class])) {
1✔
605
                                return (self::$extMethods[$name][$class])($this, ...$args);
1✔
606
                        }
607

608
                        $class = get_parent_class($class);
1✔
609
                } while ($class);
1✔
610

611
                Nette\Utils\ObjectHelpers::strictCall(static::class, $name);
1✔
612
        }
613

614

615
        /** @param callable(static): mixed  $callback */
616
        public static function extensionMethod(string $name, callable $callback): void
1✔
617
        {
618
                if (str_contains($name, '::')) { // back compatibility
1✔
UNCOV
619
                        [, $name] = explode('::', $name);
×
620
                }
621

622
                self::$extMethods[$name][static::class] = $callback;
1✔
623
        }
1✔
624
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc