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

markrogoyski / ipv4-subnet-calculator-php / 20947789662

13 Jan 2026 07:01AM UTC coverage: 97.017% (-0.8%) from 97.831%
20947789662

push

github

markrogoyski
Add CIDR aggregation and supernetting.

88 of 98 new or added lines in 1 file covered. (89.8%)

7 existing lines in 2 files now uncovered.

618 of 637 relevant lines covered (97.02%)

77.94 hits per line

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

90.91
/src/SubnetCalculatorFactory.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace IPv4;
6

7
/**
8
 * Factory class for creating SubnetCalculator instances from various input formats.
9
 *
10
 * Provides flexible ways to instantiate SubnetCalculator objects, separating
11
 * construction concerns from the main class and improving developer experience.
12
 *
13
 * @link https://datatracker.ietf.org/doc/html/rfc3021 RFC 3021 - Using 31-Bit Prefixes on IPv4 Point-to-Point Links
14
 * @link https://datatracker.ietf.org/doc/html/rfc4632 RFC 4632 - Classless Inter-domain Routing (CIDR)
15
 */
16
class SubnetCalculatorFactory
17
{
18
    /**
19
     * Create SubnetCalculator from CIDR notation string (e.g., "192.168.1.0/24")
20
     *
21
     * @param string $cidr CIDR notation (IP address with prefix, e.g., "192.168.1.0/24")
22
     *
23
     * @return SubnetCalculator
24
     *
25
     * @throws \InvalidArgumentException If CIDR format is invalid (missing slash, empty prefix)
26
     * @throws \UnexpectedValueException If IP address or network size is invalid
27
     */
28
    public static function fromCidr(string $cidr): SubnetCalculator
29
    {
30
        // Validate CIDR format
31
        if (\strpos($cidr, '/') === false) {
298✔
32
            throw new \InvalidArgumentException("Invalid CIDR notation: missing '/' prefix delimiter in '{$cidr}'");
1✔
33
        }
34

35
        $parts = \explode('/', $cidr);
297✔
36

37
        if (\count($parts) !== 2) {
297✔
38
            throw new \InvalidArgumentException("Invalid CIDR notation: multiple '/' found in '{$cidr}'");
1✔
39
        }
40

41
        [$ipAddress, $prefix] = $parts;
296✔
42

43
        if ($prefix === '') {
296✔
44
            throw new \InvalidArgumentException("Invalid CIDR notation: empty prefix in '{$cidr}'");
1✔
45
        }
46

47
        if (!\is_numeric($prefix)) {
295✔
48
            throw new \InvalidArgumentException("Invalid CIDR notation: non-numeric prefix '{$prefix}' in '{$cidr}'");
1✔
49
        }
50

51
        $networkSize = (int) $prefix;
294✔
52

53
        // Let SubnetCalculator validate IP address and network size
54
        return new SubnetCalculator($ipAddress, $networkSize);
294✔
55
    }
56

57
    /**
58
     * Create SubnetCalculator from IP address and subnet mask (e.g., "192.168.1.0", "255.255.255.0")
59
     *
60
     * @param string $ipAddress  IP address in dotted quad notation
61
     * @param string $subnetMask Subnet mask in dotted quad notation (e.g., "255.255.255.0")
62
     *
63
     * @return SubnetCalculator
64
     *
65
     * @throws \InvalidArgumentException If subnet mask is invalid or non-contiguous
66
     * @throws \UnexpectedValueException If IP address is invalid
67
     */
68
    public static function fromMask(string $ipAddress, string $subnetMask): SubnetCalculator
69
    {
70
        $networkSize = self::maskToNetworkSize($subnetMask);
21✔
71

72
        return new SubnetCalculator($ipAddress, $networkSize);
14✔
73
    }
74

75
    /**
76
     * Create SubnetCalculator from IP address range (e.g., "192.168.1.0", "192.168.1.255")
77
     *
78
     * Note: The range must represent a valid CIDR block. The start IP must be the
79
     * network address and the end IP must be the broadcast address of a valid subnet.
80
     *
81
     * @param string $startIp Start IP address (network address)
82
     * @param string $endIp   End IP address (broadcast address)
83
     *
84
     * @return SubnetCalculator
85
     *
86
     * @throws \InvalidArgumentException If range does not represent a valid CIDR block
87
     * @throws \UnexpectedValueException If IP addresses are invalid
88
     */
89
    public static function fromRange(string $startIp, string $endIp): SubnetCalculator
90
    {
91
        // Validate IP addresses
92
        $startLong = self::validateAndConvertIp($startIp);
18✔
93
        $endLong = self::validateAndConvertIp($endIp);
17✔
94

95
        // Start must be <= End
96
        if ($startLong > $endLong) {
16✔
97
            throw new \InvalidArgumentException("Start IP '{$startIp}' is greater than end IP '{$endIp}'");
1✔
98
        }
99

100
        // Calculate the number of addresses in the range
101
        $rangeSize = $endLong - $startLong + 1;
15✔
102

103
        // Check if range size is a power of 2
104
        if (($rangeSize & ($rangeSize - 1)) !== 0) {
15✔
105
            throw new \InvalidArgumentException(
4✔
106
                "Range from '{$startIp}' to '{$endIp}' does not represent a valid CIDR block (size {$rangeSize} is not a power of 2)"
4✔
107
            );
108
        }
109

110
        // Calculate network size from range size
111
        $networkSize = 32 - (int) \log($rangeSize, 2);
11✔
112

113
        // Validate that start IP is properly aligned for this network size
114
        // A properly aligned network address has all host bits set to 0
115
        $mask = self::calculateSubnetMaskInt($networkSize);
11✔
116
        if (($startLong & $mask) !== $startLong) {
11✔
117
            throw new \InvalidArgumentException(
1✔
118
                "Start IP '{$startIp}' is not a valid network address for a /{$networkSize} subnet"
1✔
119
            );
120
        }
121

122
        // Validate that end IP is the broadcast address for this network
123
        $expectedEnd = $startLong | (~$mask & 0xFFFFFFFF);
10✔
124
        if ($endLong !== $expectedEnd) {
10✔
125
            throw new \InvalidArgumentException(
×
126
                "Range from '{$startIp}' to '{$endIp}' does not represent a valid CIDR block"
×
127
            );
128
        }
129

130
        return new SubnetCalculator($startIp, $networkSize);
10✔
131
    }
132

133
    /**
134
     * Create SubnetCalculator from IP address and number of required hosts.
135
     *
136
     * Returns the smallest subnet that can accommodate the host count.
137
     *
138
     * @param string $ipAddress Base IP address
139
     * @param int    $hostCount Number of hosts required
140
     *
141
     * @return SubnetCalculator
142
     *
143
     * @throws \InvalidArgumentException If host count is invalid (zero, negative, or too large)
144
     * @throws \UnexpectedValueException If IP address is invalid
145
     */
146
    public static function fromHostCount(string $ipAddress, int $hostCount): SubnetCalculator
147
    {
148
        // Validate host count
149
        if ($hostCount <= 0) {
15✔
150
            throw new \InvalidArgumentException("Host count must be positive, got {$hostCount}");
2✔
151
        }
152

153
        // Maximum possible hosts in IPv4 (for /1 network)
154
        // 2^31 - 2 = 2147483646
155
        if ($hostCount > 2147483646) {
13✔
156
            throw new \InvalidArgumentException("Host count {$hostCount} exceeds maximum possible hosts in IPv4");
1✔
157
        }
158

159
        // Calculate optimal network size
160
        $networkSize = self::calculateOptimalNetworkSize($hostCount);
12✔
161

162
        return new SubnetCalculator($ipAddress, $networkSize);
12✔
163
    }
164

165
    /**
166
     * Calculate the optimal CIDR prefix for a given host count.
167
     *
168
     * Returns the smallest prefix (largest network) that can accommodate
169
     * the specified number of hosts.
170
     *
171
     * For standard networks (/1 to /30), usable hosts = 2^(32-prefix) - 2
172
     * For /31 (RFC 3021), usable hosts = 2
173
     * For /32, usable hosts = 1
174
     *
175
     * @param int $hostCount Number of hosts required
176
     *
177
     * @return int Optimal CIDR prefix (network size)
178
     *
179
     * @throws \InvalidArgumentException If host count is invalid (zero, negative, or too large)
180
     *
181
     * @link https://datatracker.ietf.org/doc/html/rfc3021 RFC 3021 - Using 31-Bit Prefixes on IPv4 Point-to-Point Links
182
     */
183
    public static function optimalPrefixForHosts(int $hostCount): int
184
    {
185
        // Validate host count
186
        if ($hostCount <= 0) {
29✔
187
            throw new \InvalidArgumentException("Host count must be positive, got {$hostCount}");
2✔
188
        }
189

190
        // Maximum possible hosts in IPv4 (for /1 network)
191
        // 2^31 - 2 = 2147483646
192
        if ($hostCount > 2147483646) {
27✔
193
            throw new \InvalidArgumentException("Host count {$hostCount} exceeds maximum possible hosts in IPv4");
2✔
194
        }
195

196
        return self::calculateOptimalNetworkSize($hostCount);
25✔
197
    }
198

199
    /**
200
     * Convert a subnet mask to a network size (CIDR prefix)
201
     *
202
     * @param string $subnetMask Subnet mask in dotted quad notation
203
     *
204
     * @return int Network size (CIDR prefix)
205
     *
206
     * @throws \InvalidArgumentException If mask is invalid or non-contiguous
207
     */
208
    private static function maskToNetworkSize(string $subnetMask): int
209
    {
210
        // Validate format
211
        $quads = \explode('.', $subnetMask);
21✔
212
        if (\count($quads) !== 4) {
21✔
213
            throw new \InvalidArgumentException("Invalid subnet mask format: '{$subnetMask}'");
2✔
214
        }
215

216
        // Validate each octet and convert to integer
217
        $maskInt = 0;
19✔
218
        foreach ($quads as $i => $quad) {
19✔
219
            if (!\is_numeric($quad)) {
19✔
UNCOV
220
                throw new \InvalidArgumentException("Invalid subnet mask: non-numeric octet in '{$subnetMask}'");
×
221
            }
222
            $octet = (int) $quad;
19✔
223
            if ($octet < 0 || $octet > 255) {
19✔
224
                throw new \InvalidArgumentException("Invalid subnet mask: octet out of range in '{$subnetMask}'");
1✔
225
            }
226
            $maskInt = ($maskInt << 8) | $octet;
18✔
227
        }
228

229
        // Check for zero mask (invalid - would be /0)
230
        if ($maskInt === 0) {
18✔
231
            throw new \InvalidArgumentException("Invalid subnet mask: zero mask not supported");
1✔
232
        }
233

234
        // Validate that mask is contiguous (all 1s followed by all 0s)
235
        // A valid mask in binary looks like: 11111111111111111111111100000000
236
        // If we invert it and add 1, we should get a power of 2
237
        $inverted = ~$maskInt & 0xFFFFFFFF;
17✔
238
        if (($inverted & ($inverted + 1)) !== 0) {
17✔
239
            throw new \InvalidArgumentException("Invalid subnet mask: non-contiguous mask '{$subnetMask}'");
3✔
240
        }
241

242
        // Count the number of 1 bits (network size)
243
        $networkSize = 0;
14✔
244
        $tempMask = $maskInt;
14✔
245
        while ($tempMask & 0x80000000) {
14✔
246
            $networkSize++;
14✔
247
            $tempMask <<= 1;
14✔
248
            $tempMask &= 0xFFFFFFFF; // Keep it as 32-bit
14✔
249
        }
250

251
        // Verify there are no more 1 bits after the first 0
252
        if (($tempMask & 0xFFFFFFFF) !== 0) {
14✔
UNCOV
253
            throw new \InvalidArgumentException("Invalid subnet mask: non-contiguous mask '{$subnetMask}'");
×
254
        }
255

256
        return $networkSize;
14✔
257
    }
258

259
    /**
260
     * Validate an IP address and convert to long integer
261
     *
262
     * @param string $ipAddress IP address to validate
263
     *
264
     * @return int IP address as integer
265
     *
266
     * @throws \UnexpectedValueException If IP address is invalid
267
     */
268
    private static function validateAndConvertIp(string $ipAddress): int
269
    {
270
        if (!\filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
18✔
271
            throw new \UnexpectedValueException("Invalid IP address: '{$ipAddress}'");
2✔
272
        }
273

274
        $long = \ip2long($ipAddress);
17✔
275
        if ($long === false) {
17✔
UNCOV
276
            throw new \UnexpectedValueException("Invalid IP address: '{$ipAddress}'");
×
277
        }
278

279
        // Handle negative values on 32-bit systems
280
        if ($long < 0) {
17✔
UNCOV
281
            $long = $long + 4294967296;
×
282
        }
283

284
        return $long;
17✔
285
    }
286

287
    /**
288
     * Calculate subnet mask as integer from network size
289
     *
290
     * @param int $networkSize Network size (1-32)
291
     *
292
     * @return int Subnet mask as integer
293
     */
294
    private static function calculateSubnetMaskInt(int $networkSize): int
295
    {
296
        if ($networkSize === 0) {
11✔
UNCOV
297
            return 0;
×
298
        }
299
        return (0xFFFFFFFF << (32 - $networkSize)) & 0xFFFFFFFF;
11✔
300
    }
301

302
    /**
303
     * Aggregate multiple subnets into the smallest possible supernet(s).
304
     *
305
     * Combines contiguous subnets into larger summary routes to reduce routing
306
     * table size. Returns an array of SubnetCalculator objects representing
307
     * the minimal set of CIDR blocks that cover all input subnets.
308
     *
309
     * If subnets are not fully contiguous, multiple supernets will be returned.
310
     * Overlapping and duplicate subnets are handled by removing redundant entries.
311
     *
312
     * @param SubnetCalculator[] $subnets Array of subnets to aggregate
313
     *
314
     * @return SubnetCalculator[] Array of aggregated supernets
315
     *
316
     * @link https://datatracker.ietf.org/doc/html/rfc4632 RFC 4632 - Classless Inter-domain Routing (CIDR)
317
     */
318
    public static function aggregate(array $subnets): array
319
    {
320
        if (empty($subnets)) {
27✔
321
            return [];
1✔
322
        }
323

324
        // Normalize all subnets to their network addresses and collect as [start, end, prefix] tuples
325
        $ranges = [];
26✔
326
        foreach ($subnets as $subnet) {
26✔
327
            $start = self::ipToUnsigned($subnet->getNetworkPortionInteger());
26✔
328
            $end = $start + $subnet->getNumberIPAddresses() - 1;
26✔
329
            $ranges[] = [$start, $end, $subnet->getNetworkSize()];
26✔
330
        }
331

332
        // Sort by start address, then by end address (larger ranges first)
333
        \usort($ranges, function ($a, $b) {
334
            if ($a[0] !== $b[0]) {
25✔
335
                return $a[0] <=> $b[0];
23✔
336
            }
337
            return $b[1] <=> $a[1]; // Larger ranges first when same start
2✔
338
        });
26✔
339

340
        // Remove subnets contained within others
341
        $filtered = [];
26✔
342
        foreach ($ranges as $range) {
26✔
343
            $isContained = false;
26✔
344
            foreach ($filtered as $existing) {
26✔
345
                if ($range[0] >= $existing[0] && $range[1] <= $existing[1]) {
25✔
346
                    $isContained = true;
3✔
347
                    break;
3✔
348
                }
349
            }
350
            if (!$isContained) {
26✔
351
                // Also remove any existing ranges that this one contains
352
                $filtered = \array_filter($filtered, function ($existing) use ($range) {
353
                    return !($existing[0] >= $range[0] && $existing[1] <= $range[1]);
22✔
354
                });
26✔
355
                $filtered[] = $range;
26✔
356
            }
357
        }
358

359
        // Re-sort after filtering
360
        \usort($filtered, function ($a, $b) {
361
            return $a[0] <=> $b[0];
22✔
362
        });
26✔
363

364
        // Convert back to [start, prefix] format for aggregation
365
        $blocks = [];
26✔
366
        foreach ($filtered as $range) {
26✔
367
            $blocks[] = [$range[0], $range[2]];
26✔
368
        }
369

370
        // Iteratively merge adjacent subnets
371
        $merged = true;
26✔
372
        while ($merged) {
26✔
373
            $merged = false;
26✔
374
            $newBlocks = [];
26✔
375
            $used = [];
26✔
376

377
            for ($i = 0; $i < \count($blocks); $i++) {
26✔
378
                if (isset($used[$i])) {
26✔
379
                    continue;
19✔
380
                }
381

382
                $current = $blocks[$i];
26✔
383
                $foundMerge = false;
26✔
384

385
                // Try to find a partner to merge with
386
                for ($j = $i + 1; $j < \count($blocks); $j++) {
26✔
387
                    if (isset($used[$j])) {
22✔
NEW
388
                        continue;
×
389
                    }
390

391
                    $other = $blocks[$j];
22✔
392

393
                    // Check if they can be merged (but not below /1 since /0 is not supported)
394
                    if ($current[1] === $other[1] && $current[1] > 1) {
22✔
395
                        $prefix = $current[1];
21✔
396
                        $blockSize = 1 << (32 - $prefix);
21✔
397

398
                        // Check if they are adjacent
399
                        $currentEnd = $current[0] + $blockSize - 1;
21✔
400
                        if ($currentEnd + 1 === $other[0]) {
21✔
401
                            // Check if the merged block would be aligned
402
                            $newPrefix = $prefix - 1;
20✔
403
                            $newBlockSize = 1 << (32 - $newPrefix);
20✔
404
                            if (($current[0] % $newBlockSize) === 0) {
20✔
405
                                // Merge them
406
                                $newBlocks[] = [$current[0], $newPrefix];
19✔
407
                                $used[$i] = true;
19✔
408
                                $used[$j] = true;
19✔
409
                                $merged = true;
19✔
410
                                $foundMerge = true;
19✔
411
                                break;
19✔
412
                            }
413
                        }
414
                    }
415
                }
416

417
                if (!$foundMerge) {
26✔
418
                    $newBlocks[] = $current;
26✔
419
                    $used[$i] = true;
26✔
420
                }
421
            }
422

423
            $blocks = $newBlocks;
26✔
424

425
            // Re-sort blocks by start address
426
            \usort($blocks, function ($a, $b) {
427
                return $a[0] <=> $b[0];
13✔
428
            });
26✔
429
        }
430

431
        // Convert back to SubnetCalculator objects
432
        $result = [];
26✔
433
        foreach ($blocks as $block) {
26✔
434
            $ip = self::unsignedToIp($block[0]);
26✔
435
            $result[] = new SubnetCalculator($ip, (int) $block[1]);
26✔
436
        }
437

438
        return $result;
26✔
439
    }
440

441
    /**
442
     * Find the smallest single supernet that contains all given subnets.
443
     *
444
     * Returns the minimal CIDR block that encompasses all input subnets.
445
     * Unlike aggregate(), this always returns a single subnet, but may include
446
     * addresses not in any of the input subnets (gaps are filled).
447
     *
448
     * @param SubnetCalculator[] $subnets Array of subnets to summarize
449
     *
450
     * @return SubnetCalculator The smallest supernet containing all inputs
451
     *
452
     * @throws \InvalidArgumentException If the subnet array is empty
453
     *
454
     * @link https://datatracker.ietf.org/doc/html/rfc4632 RFC 4632 - Classless Inter-domain Routing (CIDR)
455
     */
456
    public static function summarize(array $subnets): SubnetCalculator
457
    {
458
        if (empty($subnets)) {
12✔
459
            throw new \InvalidArgumentException('Cannot summarize empty subnet array');
1✔
460
        }
461

462
        if (\count($subnets) === 1) {
11✔
463
            $subnet = $subnets[0];
1✔
464
            return new SubnetCalculator($subnet->getNetworkPortion(), $subnet->getNetworkSize());
1✔
465
        }
466

467
        // Find the minimum start IP and maximum end IP
468
        $minStart = null;
10✔
469
        $maxEnd = null;
10✔
470

471
        foreach ($subnets as $subnet) {
10✔
472
            $start = self::ipToUnsigned($subnet->getNetworkPortionInteger());
10✔
473
            $end = $start + $subnet->getNumberIPAddresses() - 1;
10✔
474

475
            if ($minStart === null || $start < $minStart) {
10✔
476
                $minStart = $start;
10✔
477
            }
478
            if ($maxEnd === null || $end > $maxEnd) {
10✔
479
                $maxEnd = $end;
10✔
480
            }
481
        }
482

483
        // Find the smallest prefix that covers this range
484
        // Start from /32 and work backwards until we find one that covers the range
485
        for ($prefix = 32; $prefix >= 1; $prefix--) {
10✔
486
            $blockSize = 1 << (32 - $prefix);
10✔
487
            // Find the network address for this prefix that contains minStart
488
            $networkStart = ((int) ($minStart / $blockSize)) * $blockSize;
10✔
489
            $networkEnd = $networkStart + $blockSize - 1;
10✔
490

491
            if ($networkStart <= $minStart && $networkEnd >= $maxEnd) {
10✔
492
                $ip = self::unsignedToIp((int) $networkStart);
9✔
493
                return new SubnetCalculator($ip, $prefix);
9✔
494
            }
495
        }
496

497
        // If we get here, the subnets span the entire IP space and would require /0
498
        throw new \InvalidArgumentException(
1✔
499
            'Cannot summarize: result would require /0 which is not a valid network size'
1✔
500
        );
501
    }
502

503
    /**
504
     * Convert a signed IP integer to unsigned for comparison
505
     *
506
     * @param int $ip Signed IP integer
507
     *
508
     * @return int Unsigned IP integer
509
     */
510
    private static function ipToUnsigned(int $ip): int
511
    {
NEW
512
        if ($ip < 0) {
×
NEW
513
            return $ip + 4294967296;
×
514
        }
NEW
515
        return $ip;
×
516
    }
517

518
    /**
519
     * Convert an unsigned IP integer back to a dotted-quad string
520
     *
521
     * @param int $ip Unsigned IP integer
522
     *
523
     * @return string Dotted-quad IP address
524
     */
525
    private static function unsignedToIp(int $ip): string
526
    {
527
        // Convert to signed for long2ip if needed
NEW
528
        if ($ip > 2147483647) {
×
NEW
529
            $ip = $ip - 4294967296;
×
530
        }
NEW
531
        $result = \long2ip($ip);
×
NEW
532
        if (!$result) {
×
NEW
533
            throw new \RuntimeException("Failed to convert IP integer to string: {$ip}");
×
534
        }
NEW
535
        return $result;
×
536
    }
537

538
    /**
539
     * Calculate the optimal network size for a given host count
540
     *
541
     * For standard networks (/1 to /30), usable hosts = 2^(32-prefix) - 2
542
     * For /31 (RFC 3021), usable hosts = 2
543
     * For /32, usable hosts = 1
544
     *
545
     * @param int $hostCount Number of hosts required
546
     *
547
     * @return int Optimal network size (CIDR prefix)
548
     */
549
    private static function calculateOptimalNetworkSize(int $hostCount): int
550
    {
551
        // Special case: 1 host needs /32
552
        if ($hostCount === 1) {
37✔
553
            return 32;
2✔
554
        }
555

556
        // Special case: 2 hosts can use /31 (RFC 3021)
557
        if ($hostCount === 2) {
35✔
558
            return 31;
2✔
559
        }
560

561
        // For 3+ hosts, we need to account for network and broadcast addresses
562
        // Usable hosts = 2^(32-prefix) - 2
563
        // So we need: 2^(32-prefix) >= hostCount + 2
564
        // Therefore: 32 - prefix >= log2(hostCount + 2)
565
        // prefix <= 32 - ceil(log2(hostCount + 2))
566

567
        $totalAddressesNeeded = $hostCount + 2;
33✔
568
        $bitsNeeded = (int) \ceil(\log($totalAddressesNeeded, 2));
33✔
569
        $networkSize = 32 - $bitsNeeded;
33✔
570

571
        // Ensure network size is valid
572
        if ($networkSize < 1) {
33✔
573
            $networkSize = 1;
1✔
574
        }
575

576
        return $networkSize;
33✔
577
    }
578
}
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