33Downloads and sets up a specified PHP version on Windows.
44
55. PARAMETER Version
6- Major.minor or full version (e.g., 7 .4 or 7 .4.30 ).
6+ Major.minor or full version (e.g., 8 .4 or 8 .4.15 ).
77
8- . PARAMETER Path
9- Destination directory (defaults to C:\php<Version>).
8+ . PARAMETER Scope
9+ Auto (default), CurrentUser, AllUsers, or Custom.
10+ - Auto: AllUsers if elevated, otherwise CurrentUser.
11+ - AllUsers: Requires elevation, installs under Program Files (or Program Files (x86) for x86 arch).
12+ - CurrentUser: Installs under $env:LOCALAPPDATA.
13+ - Custom: Installs under -CustomPath (or prompts), adds to User PATH.
14+
15+ . PARAMETER CustomPath
16+ Directory for Scope=Custom. Versions are installed under this directory and a "current" link is created here.
1017
1118. PARAMETER Arch
12- Architecture: x64 or x86 (default: x64).
19+ x64 or x86 (default: x64).
1320
1421. PARAMETER ThreadSafe
15- ThreadSafe: download Thread Safe build (default: $False).
22+ Download Thread Safe build (default: $False).
1623
1724. PARAMETER Timezone
1825date.timezone string for php.ini (default: 'UTC').
26+
27+ . PARAMETER NoPrompt
28+ If set, does not prompt and uses provided parameters.
1929#>
2030
2131[CmdletBinding ()]
2232param (
2333 [Parameter (Mandatory = $true , Position = 0 )]
2434 [ValidatePattern (' ^\d+(\.\d+)?(\.\d+)?((alpha|beta|RC)\d*)?$' )]
2535 [string ]$Version ,
36+
37+ [Parameter (Mandatory = $false )]
38+ [ValidateSet (' Auto' , ' CurrentUser' , ' AllUsers' , ' Custom' )]
39+ [string ]$Scope = ' Auto' ,
40+
41+ [Parameter (Mandatory = $false )]
42+ [string ]$CustomPath ,
43+
2644 [Parameter (Mandatory = $false , Position = 1 )]
27- [string ]$Path = " C:\php$Version " ,
28- [Parameter (Mandatory = $false , Position = 2 )]
2945 [ValidateSet (" x64" , " x86" )]
3046 [string ]$Arch = " x64" ,
31- [Parameter (Mandatory = $false , Position = 3 )]
47+
48+ [Parameter (Mandatory = $false , Position = 2 )]
3249 [bool ]$ThreadSafe = $False ,
33- [Parameter (Mandatory = $false , Position = 4 )]
34- [string ]$Timezone = ' UTC'
50+
51+ [Parameter (Mandatory = $false , Position = 3 )]
52+ [string ]$Timezone = ' UTC' ,
53+
54+ [Parameter (Mandatory = $false )]
55+ [switch ]$NoPrompt
3556)
3657
3758Function Get-File {
@@ -52,19 +73,19 @@ Function Get-File {
5273 for ($i = 0 ; $i -lt $Retries ; $i ++ ) {
5374 try {
5475 if ($OutFile -ne ' ' ) {
55- Invoke-WebRequest - Uri $Url - OutFile $OutFile - TimeoutSec $TimeoutSec
76+ Invoke-WebRequest - Uri $Url - OutFile $OutFile - TimeoutSec $TimeoutSec - UseBasicParsing
5677 } else {
57- Invoke-WebRequest - Uri $Url - TimeoutSec $TimeoutSec
78+ Invoke-WebRequest - Uri $Url - TimeoutSec $TimeoutSec - UseBasicParsing
5879 }
5980 break ;
6081 } catch {
6182 if ($i -eq ($Retries - 1 )) {
6283 if ($FallbackUrl ) {
6384 try {
6485 if ($OutFile -ne ' ' ) {
65- Invoke-WebRequest - Uri $FallbackUrl - OutFile $OutFile - TimeoutSec $TimeoutSec
86+ Invoke-WebRequest - Uri $FallbackUrl - OutFile $OutFile - TimeoutSec $TimeoutSec - UseBasicParsing
6687 } else {
67- Invoke-WebRequest - Uri $FallbackUrl - TimeoutSec $TimeoutSec
88+ Invoke-WebRequest - Uri $FallbackUrl - TimeoutSec $TimeoutSec - UseBasicParsing
6889 }
6990 } catch {
7091 throw " Failed to download the file from $Url and $FallbackUrl "
@@ -77,6 +98,87 @@ Function Get-File {
7798 }
7899}
79100
101+ Function Test-IsAdmin {
102+ $p = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity ]::GetCurrent())
103+ return $p.IsInRole ([Security.Principal.WindowsBuiltInRole ]::Administrator)
104+ }
105+
106+ Function ConvertTo-BoolOrDefault {
107+ param ([string ]$UserInput , [bool ]$Default )
108+ if ([string ]::IsNullOrWhiteSpace($UserInput )) { return $Default }
109+ switch - Regex ($UserInput.Trim ().ToLowerInvariant()) {
110+ ' ^(1|true|t|y|yes)$' { return $true }
111+ ' ^(0|false|f|n|no)$' { return $false }
112+ default { return $Default }
113+ }
114+ }
115+
116+ Function Edit-PathForCompare ([string ]$p ) {
117+ if ([string ]::IsNullOrWhiteSpace($p )) { return ' ' }
118+ return ($p.Trim ().Trim(' "' ).TrimEnd(' \' )).ToLowerInvariant()
119+ }
120+
121+ Function Set-PathEntryFirst {
122+ param (
123+ [Parameter (Mandatory = $true )][ValidateSet (' User' , ' Machine' )] [string ]$Target ,
124+ [Parameter (Mandatory = $true )][string ]$Entry
125+ )
126+
127+ $entryNorm = Edit-PathForCompare $Entry
128+
129+ $existing = [Environment ]::GetEnvironmentVariable(' Path' , $Target )
130+ if ($null -eq $existing ) { $existing = ' ' }
131+
132+ $parts = @ ()
133+ foreach ($p in ($existing -split ' ;' )) {
134+ if (-not [string ]::IsNullOrWhiteSpace($p )) {
135+ if ((Edit-PathForCompare $p ) -ne $entryNorm ) { $parts += $p }
136+ }
137+ }
138+ $newParts = @ ($Entry ) + $parts
139+ [Environment ]::SetEnvironmentVariable(' Path' , ($newParts -join ' ;' ), $Target )
140+
141+ $procParts = @ ()
142+ foreach ($p in ($env: Path -split ' ;' )) {
143+ if (-not [string ]::IsNullOrWhiteSpace($p )) {
144+ if ((Edit-PathForCompare $p ) -ne $entryNorm ) { $procParts += $p }
145+ }
146+ }
147+ $env: Path = ((@ ($Entry ) + $procParts ) -join ' ;' )
148+ }
149+
150+ function Send-EnvironmentChangeBroadcast {
151+ try {
152+ $sig = @'
153+ using System;
154+ using System.Runtime.InteropServices;
155+ public static class NativeMethods {
156+ [DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)]
157+ public static extern IntPtr SendMessageTimeout(
158+ IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam,
159+ uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);
160+ }
161+ '@
162+ Add-Type - TypeDefinition $sig - ErrorAction SilentlyContinue | Out-Null
163+ $HWND_BROADCAST = [IntPtr ]0xffff
164+ $WM_SETTINGCHANGE = 0x001A
165+ $SMTO_ABORTIFHUNG = 0x0002
166+ [UIntPtr ]$result = [UIntPtr ]::Zero
167+ [NativeMethods ]::SendMessageTimeout($HWND_BROADCAST , $WM_SETTINGCHANGE , [UIntPtr ]::Zero, " Environment" , $SMTO_ABORTIFHUNG , 5000 , [ref ]$result ) | Out-Null
168+ } catch { }
169+ }
170+
171+ Function Test-EmptyDir ([string ]$Dir ) {
172+ if (-not (Test-Path - LiteralPath $Dir )) {
173+ New-Item - ItemType Directory - Path $Dir - Force | Out-Null
174+ return
175+ }
176+ $items = Get-ChildItem - LiteralPath $Dir - Force - ErrorAction SilentlyContinue
177+ if ($items -and $items.Count -gt 0 ) {
178+ throw " The directory '$Dir ' is not empty. Please choose another location."
179+ }
180+ }
181+
80182Function Get-Semver {
81183 [CmdletBinding ()]
82184 param (
@@ -85,19 +187,22 @@ Function Get-Semver {
85187 [ValidatePattern (' ^\d+\.\d+$' )]
86188 [string ]$Version
87189 )
88- $releases = Get-File - Url " https://downloads.php.net/~windows/releases/releases.json" | ConvertFrom-Json
190+
191+ $jsonUrl = " https://downloads.php.net/~windows/releases/releases.json"
192+ $releases = ((Get-File - Url $jsonUrl ).Content | ConvertFrom-Json )
193+
89194 $semver = $releases .$Version.version
90- if ($null -eq $semver ) {
91- $semver = ( Get-File - Url " https://downloads.php.net/~windows/releases/archives " ).Links |
92- Where-Object { $_ .href -match " php-( $Version .[0-9]+).* " } |
93- ForEach-Object { $matches [ 1 ] } |
94- Sort -Object { [ System.Version ] $_ } - Descending |
95- Select-Object - First 1
96- }
97- if ( $null -eq $semver ) {
98- throw " Unsupported PHP version: $Version "
99- }
100- return $semver
195+ if ($null -ne $semver ) { return [ string ] $semver }
196+
197+ $html = ( Get-File - Url " https://downloads. php.net/~windows/releases/archives/ " ).Content
198+ $rx = [ regex ] " php-( $ ( [ regex ]::Escape( $Version ) ) \.[0-9]+) "
199+ $found = $rx .Matches ( $html ) | ForEach -Object { $_ .Groups [ 1 ].Value } |
200+ Sort-Object { [ version ] $_ } - Descending |
201+ Select-Object - First 1
202+
203+ if ( $null -ne $found ) { return [ string ] $found }
204+
205+ throw " Unsupported PHP version series: $Version "
101206}
102207
103208Function Get-VSVersion {
@@ -160,7 +265,7 @@ Function Get-PhpFromUrl {
160265 $vs = Get-VSVersion $Version
161266 $ts = if ($ThreadSafe ) { " ts" } else { " nts" }
162267 $zipName = if ($ThreadSafe ) { " php-$Semver -Win32-$vs -$Arch .zip" } else { " php-$Semver -$ts -Win32-$vs -$Arch .zip" }
163- $type = Get-ReleaseType $Version
268+ $type = Get-ReleaseType $Semver
164269
165270 $base = " https://downloads.php.net/~windows/$type "
166271 try {
@@ -175,49 +280,141 @@ Function Get-PhpFromUrl {
175280}
176281
177282$tempFile = [IO.Path ]::ChangeExtension([IO.Path ]::GetTempFileName(), ' .zip' )
283+
178284try {
285+ $isAdmin = Test-IsAdmin
286+
287+ if (-not $NoPrompt ) {
288+ Write-Host " "
289+ Write-Host " 1) Would you like to install PHP for:"
290+ Write-Host " Enter 1 for Current user"
291+ Write-Host " Enter 2 for All users (requires admin elevation)"
292+ Write-Host " Enter 3 to install PHP at a custom path"
293+ Write-Host " Press Enter to choose automatically"
294+ $sel = Read-Host " Please enter [1-3]"
295+ switch ($sel ) {
296+ ' 1' { $Scope = ' CurrentUser' }
297+ ' 2' { $Scope = ' AllUsers' }
298+ ' 3' { $Scope = ' Custom' }
299+ default { $Scope = ' Auto' }
300+ }
301+
302+ Write-Host " "
303+ Write-Host " 2) What architecture would you like to install?"
304+ Write-Host " Enter x64 for 64-bit"
305+ Write-Host " Enter x86 for 32-bit"
306+ Write-Host " Press Enter to use default ($Arch )"
307+ $archSel = Read-Host " Please enter [x64/x86]"
308+ if (-not [string ]::IsNullOrWhiteSpace($archSel ) -and @ (' x64' , ' x86' ) -contains $archSel.Trim ()) {
309+ $Arch = $archSel.Trim ()
310+ }
311+
312+ Write-Host " "
313+ Write-Host " 3) What ThreadSafe option would you like to use?"
314+ Write-Host " Enter true for ThreadSafe"
315+ Write-Host " Enter false for Non-ThreadSafe"
316+ Write-Host " Press Enter to use default ($ThreadSafe )"
317+ $tsSel = Read-Host " Please enter [true/false]"
318+ $ThreadSafe = ConvertTo-BoolOrDefault - Input $tsSel - Default $ThreadSafe
319+
320+ Write-Host " "
321+ Write-Host " 4) What timezone would you like to set in php.ini?"
322+ Write-Host " Press Enter to use default ($Timezone )"
323+ $tzSel = Read-Host " Please enter timezone"
324+ if (-not [string ]::IsNullOrWhiteSpace($tzSel )) {
325+ $Timezone = $tzSel.Trim ()
326+ }
327+
328+ if ($Scope -eq ' Custom' ) {
329+ $defaultCustom = if ($CustomPath ) { $CustomPath } else { (Join-Path $env: LOCALAPPDATA ' Programs\PHP' ) }
330+ Write-Host " "
331+ Write-Host " 5) Please enter the custom installation path."
332+ Write-Host " Press Enter to use default ($defaultCustom )"
333+ $cr = Read-Host " Please enter"
334+ $CustomPath = if ([string ]::IsNullOrWhiteSpace($cr )) { $defaultCustom } else { $cr.Trim () }
335+ }
336+ }
337+
179338 if ($Version -match " ^\d+\.\d+$" ) {
180339 $Semver = Get-Semver $Version
340+ $MajorMinor = $Version
181341 } else {
182342 $Semver = $Version
183- $Semver -match ' ^(\d+\.\d+)' | Out-Null
184- $Version = $Matches [1 ]
343+ if ( $Semver -notmatch ' ^(\d+\.\d+)' ) { throw " Could not derive major.minor from Version ' $Version '. " }
344+ $MajorMinor = $Matches [1 ]
185345 }
186346
187- if (-not (Test-Path $Path )) {
188- try {
189- New-Item - ItemType Directory - Path $Path - ErrorAction Stop | Out-Null
190- } catch {
191- throw " Failed to create directory $Path . $_ "
347+ if ([version ]$MajorMinor -lt [version ]' 5.5' -and $Arch -eq ' x64' ) {
348+ $Arch = ' x86'
349+ Write-Host " PHP series $MajorMinor does not support x64 on Windows. Using x86."
350+ }
351+
352+ $EffectiveScope = $Scope
353+ if ($Scope -eq ' Auto' ) {
354+ $EffectiveScope = if ($isAdmin ) { ' AllUsers' } else { ' CurrentUser' }
355+ }
356+
357+ if ($EffectiveScope -eq ' AllUsers' -and -not $isAdmin ) {
358+ throw " AllUsers install selected but this session is not elevated. Re-run as Administrator or choose CurrentUser/Custom."
359+ }
360+
361+ $installRootDirectory = switch ($EffectiveScope ) {
362+ ' CurrentUser' { Join-Path $env: LOCALAPPDATA ' Programs\PHP' }
363+ ' AllUsers' {
364+ $pf = $env: ProgramFiles
365+ if ($Arch -eq ' x86' -and ${env: ProgramFiles(x86)} ) { $pf = ${env: ProgramFiles(x86)} }
366+ Join-Path $pf ' PHP'
192367 }
193- } else {
194- $files = Get-ChildItem - Path $Path
195- if ($files.Count -gt 0 ) {
196- throw " The directory $Path is not empty. Please provide an empty directory."
368+ ' Custom' {
369+ if ([string ]::IsNullOrWhiteSpace($CustomPath )) { throw " Scope=Custom requires -CustomPath (or interactive input)." }
370+ [Environment ]::ExpandEnvironmentVariables($CustomPath )
197371 }
372+ default { throw " Unexpected scope: $EffectiveScope " }
198373 }
199374
200- if ($Version -lt ' 5.5' -and $Arch -eq ' x64' ) {
201- $Arch = ' x86'
202- Write-Host " PHP version $Version does not support x64 architecture on Windows. Using x86 instead."
375+ if (-not (Test-Path - LiteralPath $installRootDirectory )) {
376+ New-Item - ItemType Directory - Path $installRootDirectory | Out-Null
203377 }
204378
205- Write-Host " Downloading PHP $Semver to $Path "
206- Get-PhpFromUrl $Version $Semver $Arch $ThreadSafe $tempFile
207- Expand-Archive - Path $tempFile - DestinationPath $Path - Force - ErrorAction Stop
379+ $tsTag = if ($ThreadSafe ) { ' ts' } else { ' nts' }
380+ $installDirectory = Join-Path (Join-Path (Join-Path $installRootDirectory $Semver ) $tsTag ) $Arch
381+ $currentLink = Join-Path $installRootDirectory ' current'
382+
383+ Test-EmptyDir $installDirectory
384+
385+ Write-Host " Downloading PHP $Semver ($Arch , $tsTag ) -> $installDirectory "
386+ Get-PhpFromUrl $MajorMinor $Semver $Arch $ThreadSafe $tempFile
208387
209- $phpIniProd = Join-Path $Path " php.ini-production"
388+ Expand-Archive - Path $tempFile - DestinationPath $installDirectory - Force - ErrorAction Stop
389+
390+ $phpIniProd = Join-Path $installDirectory " php.ini-production"
210391 if (-not (Test-Path $phpIniProd )) {
211- $phpIniProd = Join-Path $Path " php.ini-recommended"
392+ $phpIniProd = Join-Path $installDirectory " php.ini-recommended"
212393 }
213- $phpIni = Join-Path $Path " php.ini"
214- Copy-Item $phpIniProd $phpIni - Force
215- $extensionDir = Join-Path $Path " ext"
216- (Get-Content $phpIni ) -replace ' ^extension_dir = "./"' , " extension_dir = `" $extensionDir `" " | Set-Content $phpIni
217- (Get-Content $phpIni ) -replace ' ;\s?extension_dir = "ext"' , " extension_dir = `" $extensionDir `" " | Set-Content $phpIni
218- (Get-Content $phpIni ) -replace ' ;\s?date.timezone =' , " date.timezone = `" $Timezone `" " | Set-Content $phpIni
394+ $phpIni = Join-Path $installDirectory " php.ini"
395+ if (Test-Path $phpIniProd ) {
396+ Copy-Item $phpIniProd $phpIni - Force
397+
398+ $extensionDir = Join-Path $installDirectory " ext"
399+ (Get-Content $phpIni ) -replace ' ^extension_dir = "./"' , " extension_dir = `" $extensionDir `" " | Set-Content $phpIni
400+ (Get-Content $phpIni ) -replace ' ;\s?extension_dir = "ext"' , " extension_dir = `" $extensionDir `" " | Set-Content $phpIni
401+ (Get-Content $phpIni ) -replace ' ;\s?date.timezone =' , " date.timezone = `" $Timezone `" " | Set-Content $phpIni
402+ }
403+
404+ if (Test-Path - LiteralPath $currentLink ) {
405+ Remove-Item - LiteralPath $currentLink - Force - Recurse
406+ }
407+ New-Item - ItemType Junction - Path $currentLink - Target $installDirectory | Out-Null
408+
409+ $pathTarget = if ($EffectiveScope -eq ' AllUsers' ) { ' Machine' } else { ' User' }
410+ Set-PathEntryFirst - Target $pathTarget - Entry $currentLink
411+ Send-EnvironmentChangeBroadcast
219412
220- Write-Host " PHP $Semver downloaded to $Path "
413+ Write-Host " "
414+ Write-Host " Installed PHP ${Semver} : $installDirectory "
415+ Write-Host " It has been linked to $currentLink and added to PATH."
416+ Write-Host " Please restart any open Command Prompt/PowerShell windows or IDEs to pick up the new PATH."
417+ Write-Host " You can run 'php -v' to verify the installation in the new window."
221418} catch {
222419 Write-Error $_
223420 Exit 1
0 commit comments