PHP: How do you get a file's extension?

There is a built-in function for the file extension, and it is the one to use. The short home-made variants you find alongside it return something wrong in two very everyday cases.

The answer

<?php
  $extension = pathinfo($filename, PATHINFO_EXTENSION);
?>

That is the way. Here is what comes out of it:

bild.jpg                          -> 'jpg'
archiv.tar.gz                     -> 'gz'
LIESMICH.TXT                      -> 'TXT'
ohne_endung                       -> '' (empty)
.htaccess                         -> 'htaccess'
punkt.am.ende.                    -> '' (empty)
/pfad/zu/datei.php                -> 'php'
ordner.mit.punkt/datei            -> '' (empty)

Two lines need explaining. For archiv.tar.gz you get gz and not tar.gz — nothing in PHP knows about multi-part extensions, because the file system does not know about them either. And for .htaccess the whole name counts as the extension, because there is nothing in front of it.

Why not to home-build it

The widespread alternatives side by side:

                        substr(strrchr)  explode/end      pathinfo
bild.jpg                'jpg'            'jpg'            'jpg'
ohne_endung             ''               'ohne_endung'    ''
.htaccess               'htaccess'       'htaccess'       'htaccess'
archiv.tar.gz           'gz'             'gz'             'gz'
ordner.mit.punkt/datei  'punkt/datei'    'punkt/datei'    ''

Two rows differ.

For ohne_endung, explode/end returns the entire file name. That is the nastier of the two bugs, because a non-empty string comes back — so your code does not notice anything went wrong, and you create a file with the extension ohne_endung.

For ordner.mit.punkt/datei, strrchr() and explode() find a dot that belongs to the directory. pathinfo() knows it is looking at a path and splits correctly. Versioned directories like v1.2/ or domains used as folder names are not rare.

The case you hit immediately when handling URLs

If you pull the extension out of a URL, this happens:

pathinfo('https://example.com/bild.jpg?v=2&x=1', PATHINFO_EXTENSION)
-> 'jpg?v=2&x=1'

The query string comes along with it. pathinfo() cannot know about that, it only sees a string with a dot in it. The way around:

<?php
  $extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
?>
via parse_url : 'jpg'

A fragment (#something) would be picked up just the same, by the way. parse_url() clears both away.

pathinfo() without the constant

Without the second parameter you get an array. And the extension key is missing entirely when there is no extension — it is not empty:

pathinfo('ohne_endung')  keys : ["dirname","basename","filename"]
pathinfo('bild.jpg')     keys : ["dirname","basename","extension","filename"]
isset($p['extension'])        : false

So a direct $p['extension'] raises a Warning: Undefined array key. Either guard it with ?? or use the constant in the first place — that returns an empty string in this case.

Upper and lower case

pathinfo() returns the extension exactly as written:

pathinfo('LIESMICH.TXT') : 'TXT'
strtolower of that       : 'txt'

If you check against a list of permitted extensions, a strtolower() belongs in between. Otherwise somebody gets past your check with .JPG — or fails to get past it although they should.

And now the important part: the extension says nothing about the content

This is the reason I am writing this article at all. A file name is a claim, not a proof. I named a file containing PHP code harmlos.jpg:

file name              : harmlos.jpg
extension per pathinfo : jpg
finfo says             : text/x-php
getimagesize()         : false

pathinfo() dutifully says jpg. finfo looks inside the file and says text/x-php. If your upload check only looks at the extension, there is now executable PHP code sitting in your upload directory.

The reverse happens too — a genuine PNG that somebody named .txt:

extension per pathinfo : txt
finfo says             : image/png
getimagesize()         : 2 x 2, mime image/png

For uploads that means:

<?php
  $fi   = new finfo(FILEINFO_MIME_TYPE);
  $type = $fi->file($_FILES['file']['tmp_name']);

  $allowed = [
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/webp' => 'webp',
  ];

  if (!isset($allowed[$type])) {
    throw new RuntimeException('file type not permitted: ' . $type);
  }

  // Assign the extension yourself, do not take the user's.
  $target = $directory . '/' . bin2hex(random_bytes(16))
            . '.' . $allowed[$type];
?>

Three things about this matter. First, the type is determined from the content. Second, the list is an allowlist — whatever is not in it is forbidden. And third, you assign the file name yourself instead of taking the user’s.

What you should not use is $_FILES['file']['type']. That value comes from the upload request, so from the client, and can be set just as freely as any other header.

On top of that, the upload directory belongs configured so that nothing is executed there — outside the document root, or with a matching web server rule. Do not rely on the check alone being enough.

Multibyte file names

Briefly, because the question comes up regularly:

Übung.pdf  -> pdf
файл.txt   -> txt
日本語.md   -> md

Works. The dot is unambiguous in UTF-8, and the extension itself is as a rule ASCII.

Summary

  • pathinfo($name, PATHINFO_EXTENSION) and nothing else.
  • substr(strrchr(...)) and explode/end get it wrong for files without an extension and for dots in directory names.
  • For URLs, parse_url(..., PHP_URL_PATH) first, otherwise the query string comes along.
  • Without the constant the extension key is missing entirely.
  • Do not forget strtolower() when checking against a list.
  • For uploads check the content (finfo), not the name — and not $_FILES[...]['type'], which comes from the client.

About Netcup (advertisement)

The German host Netcup offers, among other things, affordable and powerful web hosting packages, KVM-based root servers and dedicated servers. With our voucher codes you can save even more (6€ off your first order, 30% off all KVM-based root servers, ...).