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

nette / forms / 21852420656

10 Feb 2026 05:00AM UTC coverage: 93.114% (+0.04%) from 93.076%
21852420656

push

github

dg
netteForms: restructured package, includes UMD and ESM (BC break)

2069 of 2222 relevant lines covered (93.11%)

0.93 hits per line

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

92.63
/src/Forms/Form.php
1
<?php
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
declare(strict_types=1);
9

10
namespace Nette\Forms;
11

12
use Nette;
13
use Nette\Utils\Arrays;
14
use Nette\Utils\Html;
15
use Stringable;
16
use function array_key_first, array_merge, array_search, array_unique, count, in_array, is_array, is_scalar, is_string, sprintf, strcasecmp, strtolower;
17

18

19
/**
20
 * Creates, validates and renders HTML forms.
21
 *
22
 * @property-read string[] $errors
23
 * @property-read array<string|Stringable> $ownErrors
24
 * @property-read Html $elementPrototype
25
 */
26
class Form extends Container implements Nette\HtmlStringable
27
{
28
        /** validator */
29
        public const
30
                Equal = ':equal',
31
                IsIn = self::Equal,
32
                NotEqual = ':notEqual',
33
                IsNotIn = self::NotEqual,
34
                Filled = ':filled',
35
                Blank = ':blank',
36
                Required = self::Filled,
37
                Valid = ':valid',
38

39
                // button
40
                Submitted = ':submitted',
41

42
                // text
43
                MinLength = ':minLength',
44
                MaxLength = ':maxLength',
45
                Length = ':length',
46
                Email = ':email',
47
                URL = ':url',
48
                Pattern = ':pattern',
49
                PatternInsensitive = ':patternCaseInsensitive',
50
                Integer = ':integer',
51
                Numeric = ':numeric',
52
                Float = ':float',
53
                Min = ':min',
54
                Max = ':max',
55
                Range = ':range',
56

57
                // multiselect
58
                Count = self::Length,
59

60
                // file upload
61
                MaxFileSize = ':fileSize',
62
                MimeType = ':mimeType',
63
                Image = ':image',
64
                MaxPostSize = ':maxPostSize';
65

66
        /** method */
67
        public const
68
                Get = 'get',
69
                Post = 'post';
70

71
        /** submitted data types */
72
        public const
73
                DataText = 1,
74
                DataLine = 2,
75
                DataFile = 3,
76
                DataKeys = 8;
77

78
        /** @internal tracker ID */
79
        public const TrackerId = '_form_';
80

81
        /** @internal protection token ID */
82
        public const ProtectorId = '_token_';
83

84
        #[\Deprecated('use Form::Equal')]
85
        public const EQUAL = self::Equal;
86

87
        #[\Deprecated('use Form::IsIn')]
88
        public const IS_IN = self::IsIn;
89

90
        #[\Deprecated('use Form::NotEqual')]
91
        public const NOT_EQUAL = self::NotEqual;
92

93
        #[\Deprecated('use Form::IsNotIn')]
94
        public const IS_NOT_IN = self::IsNotIn;
95

96
        #[\Deprecated('use Form::Filled')]
97
        public const FILLED = self::Filled;
98

99
        #[\Deprecated('use Form::Blank')]
100
        public const BLANK = self::Blank;
101

102
        #[\Deprecated('use Form::Required')]
103
        public const REQUIRED = self::Required;
104

105
        #[\Deprecated('use Form::Valid')]
106
        public const VALID = self::Valid;
107

108
        #[\Deprecated('use Form::Submitted')]
109
        public const SUBMITTED = self::Submitted;
110

111
        #[\Deprecated('use Form::MinLength')]
112
        public const MIN_LENGTH = self::MinLength;
113

114
        #[\Deprecated('use Form::MaxLength')]
115
        public const MAX_LENGTH = self::MaxLength;
116

117
        #[\Deprecated('use Form::Length')]
118
        public const LENGTH = self::Length;
119

120
        #[\Deprecated('use Form::Email')]
121
        public const EMAIL = self::Email;
122

123
        #[\Deprecated('use Form::Pattern')]
124
        public const PATTERN = self::Pattern;
125

126
        #[\Deprecated('use Form::PatternCI')]
127
        public const PATTERN_ICASE = self::PatternInsensitive;
128

129
        #[\Deprecated('use Form::Integer')]
130
        public const INTEGER = self::Integer;
131

132
        #[\Deprecated('use Form::Numeric')]
133
        public const NUMERIC = self::Numeric;
134

135
        #[\Deprecated('use Form::Float')]
136
        public const FLOAT = self::Float;
137

138
        #[\Deprecated('use Form::Min')]
139
        public const MIN = self::Min;
140

141
        #[\Deprecated('use Form::Max')]
142
        public const MAX = self::Max;
143

144
        #[\Deprecated('use Form::Range')]
145
        public const RANGE = self::Range;
146

147
        #[\Deprecated('use Form::Count')]
148
        public const COUNT = self::Count;
149

150
        #[\Deprecated('use Form::MaxFileSize')]
151
        public const MAX_FILE_SIZE = self::MaxFileSize;
152

153
        #[\Deprecated('use Form::MimeType')]
154
        public const MIME_TYPE = self::MimeType;
155

156
        #[\Deprecated('use Form::Image')]
157
        public const IMAGE = self::Image;
158

159
        #[\Deprecated('use Form::MaxPostSize')]
160
        public const MAX_POST_SIZE = self::MaxPostSize;
161

162
        #[\Deprecated('use Form::Get')]
163
        public const GET = self::Get;
164

165
        #[\Deprecated('use Form::Post')]
166
        public const POST = self::Post;
167

168
        #[\Deprecated('use Form::DataText')]
169
        public const DATA_TEXT = self::DataText;
170

171
        #[\Deprecated('use Form::DataLine')]
172
        public const DATA_LINE = self::DataLine;
173

174
        #[\Deprecated('use Form::DataFile')]
175
        public const DATA_FILE = self::DataFile;
176

177
        #[\Deprecated('use Form::DataKeys')]
178
        public const DATA_KEYS = self::DataKeys;
179

180
        #[\Deprecated('use Form::TrackerId')]
181
        public const TRACKER_ID = self::TrackerId;
182

183
        #[\Deprecated('use Form::ProtectorId')]
184
        public const PROTECTOR_ID = self::ProtectorId;
185

186
        /**
187
         * Occurs when the form is submitted and successfully validated
188
         * @var array<callable(self, mixed[]|object): void | callable(mixed[]|object): void>
189
         */
190
        public array $onSuccess = [];
191

192
        /** @var array<callable(self): void>  Occurs when the form is submitted and is not valid */
193
        public array $onError = [];
194

195
        /** @var array<callable(self): void>  Occurs when the form is submitted */
196
        public array $onSubmit = [];
197

198
        /** @var array<callable(self): void>  Occurs before the form is rendered */
199
        public array $onRender = [];
200

201
        /** @internal used only by standalone form */
202
        public Nette\Http\IRequest $httpRequest;
203
        protected bool $crossOrigin = false;
204
        private static ?Nette\Http\IRequest $defaultHttpRequest = null;
205
        private SubmitterControl|bool $submittedBy = false;
206

207
        /** @var mixed[] */
208
        private array $httpData;
209
        private Html $element;
210
        private FormRenderer $renderer;
211
        private ?Nette\Localization\Translator $translator = null;
212

213
        /** @var ControlGroup[] */
214
        private array $groups = [];
215

216
        /** @var list<string|Stringable> */
217
        private array $errors = [];
218
        private bool $beforeRenderCalled = false;
219

220

221
        public function __construct(?string $name = null)
1✔
222
        {
223
                if ($name !== null) {
1✔
224
                        $this->getElementPrototype()->id = 'frm-' . $name;
1✔
225
                        $tracker = new Controls\HiddenField($name);
1✔
226
                        $tracker->setOmitted();
1✔
227
                        $this[self::TrackerId] = $tracker;
1✔
228
                        $this->setParent(null, $name);
1✔
229
                }
230

231
                $this->monitor(self::class, function (): void {
1✔
232
                        throw new Nette\InvalidStateException('Nested forms are forbidden.');
233
                });
1✔
234
        }
1✔
235

236

237
        /**
238
         * Returns self.
239
         */
240
        public function getForm(bool $throw = true): static
1✔
241
        {
242
                return $this;
1✔
243
        }
244

245

246
        /**
247
         * Sets form's action.
248
         */
249
        public function setAction(string|Stringable $url): static
1✔
250
        {
251
                $this->getElementPrototype()->action = $url;
1✔
252
                return $this;
1✔
253
        }
254

255

256
        /**
257
         * Returns form's action.
258
         */
259
        public function getAction(): string|Stringable
260
        {
261
                return $this->getElementPrototype()->action;
1✔
262
        }
263

264

265
        /**
266
         * Sets form's method GET or POST.
267
         */
268
        public function setMethod(string $method): static
1✔
269
        {
270
                if (isset($this->httpData)) {
1✔
271
                        throw new Nette\InvalidStateException(__METHOD__ . '() must be called until the form is empty.');
×
272
                }
273

274
                $this->getElementPrototype()->method = strtolower($method);
1✔
275
                return $this;
1✔
276
        }
277

278

279
        /**
280
         * Returns form's method.
281
         */
282
        public function getMethod(): string
283
        {
284
                return $this->getElementPrototype()->method;
1✔
285
        }
286

287

288
        /**
289
         * Checks if the request method is the given one.
290
         */
291
        public function isMethod(string $method): bool
1✔
292
        {
293
                return strcasecmp($this->getElementPrototype()->method, $method) === 0;
1✔
294
        }
295

296

297
        /**
298
         * Changes forms's HTML attribute.
299
         */
300
        public function setHtmlAttribute(string $name, mixed $value = true): static
301
        {
302
                $this->getElementPrototype()->$name = $value;
×
303
                return $this;
×
304
        }
305

306

307
        /**
308
         * Disables CSRF protection using a SameSite cookie.
309
         */
310
        public function allowCrossOrigin(): void
311
        {
312
                $this->crossOrigin = true;
1✔
313
        }
1✔
314

315

316
        /**
317
         * @deprecated default protection is sufficient
318
         */
319
        public function addProtection(?string $errorMessage = null): Controls\CsrfProtection
1✔
320
        {
321
                $control = new Controls\CsrfProtection($errorMessage);
1✔
322
                $children = $this->getComponents();
1✔
323
                $first = $children ? (string) array_key_first($children) : null;
1✔
324
                $this->addComponent($control, self::ProtectorId, $first);
1✔
325
                return $control;
1✔
326
        }
327

328

329
        /**
330
         * Adds fieldset group to the form.
331
         */
332
        public function addGroup(string|Stringable|null $caption = null, bool $setAsCurrent = true): ControlGroup
1✔
333
        {
334
                $group = new ControlGroup;
1✔
335
                $group->setOption('label', $caption);
1✔
336
                $group->setOption('visual', true);
1✔
337

338
                if ($setAsCurrent) {
1✔
339
                        $this->setCurrentGroup($group);
1✔
340
                }
341

342
                return !is_scalar($caption) || isset($this->groups[$caption])
1✔
343
                        ? $this->groups[] = $group
1✔
344
                        : $this->groups[$caption] = $group;
1✔
345
        }
346

347

348
        /**
349
         * Removes fieldset group from form.
350
         */
351
        public function removeGroup(string|ControlGroup $name): void
352
        {
353
                if (is_string($name) && isset($this->groups[$name])) {
×
354
                        $group = $this->groups[$name];
×
355

356
                } elseif ($name instanceof ControlGroup && in_array($name, $this->groups, strict: true)) {
×
357
                        $group = $name;
×
358
                        $name = array_search($group, $this->groups, strict: true);
×
359

360
                } else {
361
                        throw new Nette\InvalidArgumentException("Group not found in form '{$this->getName()}'");
×
362
                }
363

364
                foreach ($group->getControls() as $control) {
×
365
                        $control->getParent()->removeComponent($control);
×
366
                }
367

368
                unset($this->groups[$name]);
×
369
        }
370

371

372
        /**
373
         * Returns all defined groups.
374
         * @return ControlGroup[]
375
         */
376
        public function getGroups(): array
377
        {
378
                return $this->groups;
1✔
379
        }
380

381

382
        /**
383
         * Returns the specified group.
384
         */
385
        public function getGroup(string|int $name): ?ControlGroup
1✔
386
        {
387
                return $this->groups[$name] ?? null;
1✔
388
        }
389

390

391
        /********************* translator ****************d*g**/
392

393

394
        /**
395
         * Sets translate adapter.
396
         */
397
        public function setTranslator(?Nette\Localization\Translator $translator): static
1✔
398
        {
399
                $this->translator = $translator;
1✔
400
                return $this;
1✔
401
        }
402

403

404
        /**
405
         * Returns translate adapter.
406
         */
407
        public function getTranslator(): ?Nette\Localization\Translator
408
        {
409
                return $this->translator;
1✔
410
        }
411

412

413
        /********************* submission ****************d*g**/
414

415

416
        /**
417
         * Tells if the form is anchored.
418
         */
419
        public function isAnchored(): bool
420
        {
421
                return true;
1✔
422
        }
423

424

425
        /**
426
         * Tells if the form was submitted.
427
         */
428
        public function isSubmitted(): SubmitterControl|bool
429
        {
430
                if (!isset($this->httpData)) {
1✔
431
                        $this->getHttpData();
1✔
432
                }
433

434
                return $this->submittedBy;
1✔
435
        }
436

437

438
        /**
439
         * Tells if the form was submitted and successfully validated.
440
         */
441
        public function isSuccess(): bool
442
        {
443
                return $this->isSubmitted() && $this->isValid();
1✔
444
        }
445

446

447
        /**
448
         * Sets the submittor control.
449
         * @internal
450
         */
451
        public function setSubmittedBy(?SubmitterControl $by): static
1✔
452
        {
453
                $this->submittedBy = $by ?? false;
1✔
454
                return $this;
1✔
455
        }
456

457

458
        /**
459
         * Returns submitted HTTP data.
460
         * @return string|string[]|Nette\Http\FileUpload|null
461
         */
462
        public function getHttpData(?int $type = null, ?string $htmlName = null): string|array|Nette\Http\FileUpload|null
1✔
463
        {
464
                if (!isset($this->httpData)) {
1✔
465
                        if (!$this->isAnchored()) {
1✔
466
                                throw new Nette\InvalidStateException('Form is not anchored and therefore can not determine whether it was submitted.');
×
467
                        }
468

469
                        $data = $this->receiveHttpData();
1✔
470
                        $this->httpData = (array) $data;
1✔
471
                        $this->submittedBy = is_array($data);
1✔
472
                }
473

474
                return $htmlName === null
1✔
475
                        ? $this->httpData
1✔
476
                        : Helpers::extractHttpData($this->httpData, $htmlName, $type);
1✔
477
        }
478

479

480
        /**
481
         * Fires submit/click events.
482
         */
483
        public function fireEvents(): void
484
        {
485
                if (!$this->isSubmitted()) {
1✔
486
                        return;
1✔
487

488
                } elseif (!$this->getErrors()) {
1✔
489
                        $this->validate();
1✔
490
                }
491

492
                $handled = count($this->onSuccess ?? []) || count($this->onSubmit ?? []) || $this->submittedBy === true;
1✔
493

494
                if ($this->submittedBy instanceof Controls\SubmitButton) {
1✔
495
                        $handled = $handled || count($this->submittedBy->onClick ?? []);
1✔
496
                        if ($this->isValid()) {
1✔
497
                                $this->invokeHandlers($this->submittedBy->onClick, $this->submittedBy);
1✔
498
                        } else {
499
                                Arrays::invoke($this->submittedBy->onInvalidClick, $this->submittedBy);
1✔
500
                        }
501
                }
502

503
                if ($this->isValid()) {
1✔
504
                        $this->invokeHandlers($this->onSuccess);
1✔
505
                }
506

507
                if (!$this->isValid()) {
1✔
508
                        Arrays::invoke($this->onError, $this);
1✔
509
                }
510

511
                Arrays::invoke($this->onSubmit, $this);
1✔
512

513
                if (!$handled) {
1✔
514
                        trigger_error("Form was submitted but there are no associated handlers (form '{$this->getName()}').", E_USER_WARNING);
1✔
515
                }
516
        }
1✔
517

518

519
        /** @param  iterable<callable>  $handlers */
520
        private function invokeHandlers(iterable $handlers, ?SubmitterControl $button = null): void
1✔
521
        {
522
                foreach ($handlers as $handler) {
1✔
523
                        $params = Nette\Utils\Callback::toReflection($handler)->getParameters();
1✔
524
                        $args = [];
1✔
525
                        if ($params) {
1✔
526
                                $type = Helpers::getSingleType($params[0]);
1✔
527
                                $args[] = match (true) {
1✔
528
                                        !$type => $button ?? $this,
1✔
529
                                        $this instanceof $type => $this,
1✔
530
                                        $button instanceof $type => $button,
1✔
531
                                        default => $this->getValues($type),
1✔
532
                                };
533
                                if (isset($params[1])) {
1✔
534
                                        $args[] = $this->getValues(Helpers::getSingleType($params[1]));
1✔
535
                                }
536
                        }
537

538
                        $handler(...$args);
1✔
539

540
                        if (!$this->isValid()) {
1✔
541
                                return;
1✔
542
                        }
543
                }
544
        }
1✔
545

546

547
        /**
548
         * Resets form.
549
         */
550
        public function reset(): static
551
        {
552
                $this->setSubmittedBy(null);
1✔
553
                $this->setValues([], erase: true);
1✔
554
                return $this;
1✔
555
        }
556

557

558
        /**
559
         * Internal: returns submitted HTTP data or null when form was not submitted.
560
         * @return ?mixed[]
561
         */
562
        protected function receiveHttpData(): ?array
563
        {
564
                $httpRequest = $this->getHttpRequest();
1✔
565
                if (strcasecmp($this->getMethod(), $httpRequest->getMethod())) {
1✔
566
                        return null;
1✔
567
                }
568

569
                if ($httpRequest->isMethod('post')) {
1✔
570
                        if (!$this->crossOrigin && !$httpRequest->isFrom('same-origin')) {
1✔
571
                                return null;
1✔
572
                        }
573

574
                        $data = Nette\Utils\Arrays::mergeTree($httpRequest->getPost(), $httpRequest->getFiles());
1✔
575
                } else {
576
                        $data = $httpRequest->getQuery();
1✔
577
                        if (!$data) {
1✔
578
                                return null;
1✔
579
                        }
580
                }
581

582
                if ($tracker = $this->getComponent(self::TrackerId, throw: false)) {
1✔
583
                        if (!isset($data[self::TrackerId]) || $data[self::TrackerId] !== $tracker->getValue()) {
1✔
584
                                return null;
×
585
                        }
586
                }
587

588
                return $data;
1✔
589
        }
590

591

592
        /********************* validation ****************d*g**/
593

594

595
        /** @param  ?(Control|Container)[]  $controls */
596
        public function validate(?array $controls = null): void
1✔
597
        {
598
                $this->cleanErrors();
1✔
599
                if ($controls === null && $this->submittedBy instanceof SubmitterControl) {
1✔
600
                        $controls = $this->submittedBy->getValidationScope();
1✔
601
                }
602

603
                $this->validateMaxPostSize();
1✔
604
                parent::validate($controls);
1✔
605
        }
1✔
606

607

608
        /** @internal */
609
        public function validateMaxPostSize(): void
610
        {
611
                if (!$this->submittedBy || !$this->isMethod('post') || empty($_SERVER['CONTENT_LENGTH'])) {
1✔
612
                        return;
1✔
613
                }
614

615
                $maxSize = Helpers::iniGetSize('post_max_size');
1✔
616
                if ($maxSize > 0 && $maxSize < $_SERVER['CONTENT_LENGTH']) {
1✔
617
                        $this->addError(sprintf(Validator::$messages[self::MaxFileSize], $maxSize));
1✔
618
                }
619
        }
1✔
620

621

622
        /**
623
         * Adds global error message.
624
         */
625
        public function addError(string|Stringable $message, bool $translate = true): void
1✔
626
        {
627
                if ($translate && $this->translator) {
1✔
628
                        $message = $this->translator->translate($message);
1✔
629
                }
630

631
                $this->errors[] = $message;
1✔
632
        }
1✔
633

634

635
        /**
636
         * Returns global validation errors.
637
         * @return string[]
638
         */
639
        public function getErrors(): array
640
        {
641
                return array_unique(array_merge($this->errors, parent::getErrors()));
1✔
642
        }
643

644

645
        public function hasErrors(): bool
646
        {
647
                return (bool) $this->getErrors();
1✔
648
        }
649

650

651
        public function cleanErrors(): void
652
        {
653
                $this->errors = [];
1✔
654
        }
1✔
655

656

657
        /**
658
         * Returns form's validation errors.
659
         * @return list<string|Stringable>
660
         */
661
        public function getOwnErrors(): array
662
        {
663
                return array_unique($this->errors);
1✔
664
        }
665

666

667
        /********************* rendering ****************d*g**/
668

669

670
        /**
671
         * Returns form's HTML element template.
672
         */
673
        public function getElementPrototype(): Html
674
        {
675
                if (!isset($this->element)) {
1✔
676
                        $this->element = Html::el('form');
1✔
677
                        $this->element->action = ''; // RFC 1808 -> empty uri means 'this'
1✔
678
                        $this->element->method = self::Post;
1✔
679
                }
680

681
                return $this->element;
1✔
682
        }
683

684

685
        /**
686
         * Sets form renderer.
687
         */
688
        public function setRenderer(?FormRenderer $renderer): static
1✔
689
        {
690
                $this->renderer = $renderer;
1✔
691
                return $this;
1✔
692
        }
693

694

695
        /**
696
         * Returns form renderer.
697
         */
698
        public function getRenderer(): FormRenderer
699
        {
700
                if (!isset($this->renderer)) {
1✔
701
                        $this->renderer = new Rendering\DefaultFormRenderer;
1✔
702
                }
703

704
                return $this->renderer;
1✔
705
        }
706

707

708
        protected function beforeRender()
709
        {
710
        }
1✔
711

712

713
        /**
714
         * Must be called before form is rendered and render() is not used.
715
         */
716
        public function fireRenderEvents(): void
717
        {
718
                if (!$this->beforeRenderCalled) {
1✔
719
                        $this->beforeRenderCalled = true;
1✔
720
                        $this->beforeRender();
1✔
721
                        Arrays::invoke($this->onRender, $this);
1✔
722
                }
723
        }
1✔
724

725

726
        /**
727
         * Renders form.
728
         */
729
        public function render(mixed ...$args): void
1✔
730
        {
731
                $this->fireRenderEvents();
1✔
732
                echo $this->getRenderer()->render($this, ...$args);
1✔
733
        }
1✔
734

735

736
        /**
737
         * Renders form to string.
738
         */
739
        public function __toString(): string
740
        {
741
                $this->fireRenderEvents();
1✔
742
                return $this->getRenderer()->render($this);
1✔
743
        }
744

745

746
        /** @return array<string, bool> */
747
        public function getToggles(): array
748
        {
749
                $toggles = [];
1✔
750
                foreach ($this->getComponentTree() as $control) {
1✔
751
                        if ($control instanceof Controls\BaseControl) {
1✔
752
                                $toggles = $control->getRules()->getToggleStates($toggles);
1✔
753
                        }
754
                }
755

756
                return $toggles;
1✔
757
        }
758

759

760
        /********************* backend ****************d*g**/
761

762

763
        /**
764
         * Initialize standalone forms.
765
         */
766
        public static function initialize(bool|Nette\Http\IRequest $reinit = false): void
1✔
767
        {
768
                self::$defaultHttpRequest = match ($reinit) {
1✔
769
                        true => null,
1✔
770
                        false => self::$defaultHttpRequest ?? (new Nette\Http\RequestFactory)->fromGlobals(),
1✔
771
                        default => $reinit,
1✔
772
                };
773
        }
1✔
774

775

776
        private function getHttpRequest(): Nette\Http\IRequest
777
        {
778
                if (!isset($this->httpRequest)) {
1✔
779
                        self::initialize();
1✔
780
                        $this->httpRequest = self::$defaultHttpRequest;
1✔
781
                }
782

783
                return $this->httpRequest;
1✔
784
        }
785
}
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